企业模式:PoEAA 在判题平台的落地
这一课你将能做到
- 说出事务脚本、领域模型、表模块、活动记录四种企业模式,并判断判题平台该用哪种
- 用 Identity Map 解决"重复查询同一用户"的性能问题
- 用 Unit of Work 管理一次提交涉及的多个数据库变更
为什么要学
PoEAA(Patterns of Enterprise Application Architecture)是企业级应用的"组织代码"方式。判题平台不是 demo,是真实业务:用户提交代码、判题引擎跑用例、更新练习记录、发通知、算排行榜。这些操作怎么组织、怎么保证数据一致、怎么避免 N+1 查询,就是 PoEAA 要解决的问题。
先建立图像
事务脚本 领域模型 表模块
───────── ───────── ─────────
一个方法一个事务 对象有行为 一个类管一张表
过程式 面向对象 数据 + 简单操作
适合简单 CRUD 适合复杂业务 适合报表类
活动记录 Identity Map Unit of Work
───────── ───────── ─────────
对象自己管 CRUD 缓存已加载对象 收集变更批量提交
Rails/Django 风格 避免重复查询 保证事务完整性
跟着做
步骤 1:事务脚本 vs 领域模型
事务脚本(适合简单业务):
@Service
public class SubmissionTransactionScript {
@Autowired private SubmissionMapper submissionMapper;
@Autowired private PracticeRecordMapper practiceMapper;
@Transactional
public void submitAndJudge(SubmitRequest req) {
// 一个方法一个事务,过程式
Submission submission = new Submission();
submission.setUserId(req.userId());
submission.setLessonId(req.lessonId());
submission.setCode(req.code());
submission.setStatus(0); // PENDING
submissionMapper.insert(submission);
// 判题逻辑也在同一个事务里
JudgeResult result = judgeClient.call(req.code(), req.lessonId());
submission.setStatus(result.isAccepted() ? 1 : 2);
submissionMapper.updateById(submission);
practiceMapper.upsert(req.userId(), req.lessonId(), result.isAccepted());
}
}
优点:简单直接;缺点:业务规则散落,复用差,测试难。
领域模型(适合复杂业务):
public class Submission {
private SubmissionId id;
private CodeSnippet code;
private SubmissionStatus status;
public void judge(JudgeEngine engine) {
JudgeResult result = engine.judge(this);
this.status = result.isAccepted() ? ACCEPTED : REJECTED;
}
}
@Service
public class SubmissionService {
@Autowired private SubmissionRepository repository;
@Autowired private JudgeEngineFactory engineFactory;
@Transactional
public void submitAndJudge(SubmitCommand cmd) {
Submission submission = Submission.create(cmd);
JudgeEngine engine = engineFactory.create(cmd.language());
submission.judge(engine); // 业务规则在领域对象里
repository.save(submission);
}
}
业务规则在 Submission.judge() 里,Service 只协调,不实现规则。
步骤 2:Identity Map——避免重复查询
@Component
public class UserIdentityMap {
private final Map<UserId, CfUser> cache = new ConcurrentHashMap<>();
public CfUser get(UserId id, Function<UserId, CfUser> loader) {
return cache.computeIfAbsent(id, loader);
}
public void clear() { cache.clear(); }
}
@Service
public class SubmissionQueryService {
@Autowired private UserIdentityMap userMap;
@Autowired private UserMapper userMapper;
public List<SubmissionDto> listByLesson(long lessonId) {
List<Submission> submissions = submissionMapper.selectByLesson(lessonId);
return submissions.stream().map(s -> {
// 同一用户多次出现也只查一次
CfUser user = userMap.get(s.getUserId(), id -> userMapper.selectById(id.value()));
return SubmissionDto.of(s, user.getNickname());
}).toList();
}
}
一个请求内,同一用户只查一次数据库,后续从 Identity Map 拿。
步骤 3:Unit of Work——批量提交变更
@Component
public class UnitOfWork {
private final List<Object> newObjects = new ArrayList<>();
private final List<Object> dirtyObjects = new ArrayList<>();
private final List<Object> removedObjects = new ArrayList<>();
public void registerNew(Object obj) { newObjects.add(obj); }
public void registerDirty(Object obj) { dirtyObjects.add(obj); }
public void registerRemoved(Object obj) { removedObjects.add(obj); }
@Transactional
public void commit() {
// 统一在事务内批量执行
newObjects.forEach(this::insert);
dirtyObjects.forEach(this::update);
removedObjects.forEach(this::delete);
clear();
}
}
判题平台里,一次提交要改 Submission、PracticeRecord、UserStat 三张表,Unit of Work 收集所有变更,最后一次事务提交。
讲清楚要点
- 事务脚本:一个方法一个事务,过程式,适合简单 CRUD。
- 领域模型:对象有行为,业务规则内聚,适合复杂业务。
- 表模块:一个类管一张表,数据 + 简单操作,适合报表。
- 活动记录:对象自己管 CRUD,Rails/Django 风格,Java 里少用。
- Identity Map:缓存已加载对象,避免同一请求内重复查询。
- Unit of Work:收集变更,批量提交,保证事务完整性。
你可能会懵的地方
- 事务脚本和领域模型怎么选? 简单 CRUD 用事务脚本,复杂业务用领域模型。判题平台核心域(提交、判题)用领域模型,边缘域(日志、通知)可以用事务脚本。
- Identity Map 和缓存什么区别? Identity Map 是请求级缓存,防止同一请求内重复查询;Redis 是跨请求缓存。Identity Map 解决 N+1,Redis 解决热点数据。
- Unit of Work 和事务什么关系? Unit of Work 收集变更,事务保证提交原子性。Unit of Work 是"收集器",事务是"执行器"。
- Spring 的
@Transactional和 Unit of Work 什么关系?@Transactional是声明式事务,Unit of Work 是变更收集模式。可以结合:Service 方法加@Transactional,内部用 Unit of Work 收集变更。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 一个 Service 方法 200 行 | 事务脚本失控 | 拆成领域模型,规则进对象 |
| 同一用户查 10 次 | N+1 性能问题 | Identity Map 请求级缓存 |
| 多次 save 不统一提交 | 事务边界不清 | Unit of Work 收集后批量提交 |
| 业务规则写在 SQL 里 | 难维护,难测试 | 规则进领域对象,SQL 只做存取 |
| 所有表都用领域模型 | 过度设计 | 边缘域用事务脚本,核心域用领域模型 |
卡点急救
- 事务脚本越来越长:找重复的业务规则,抽成领域对象方法;Service 只协调,不实现。
- Identity Map 内存泄漏:请求结束清空;或者用
ThreadLocal+try-finally保证清理。 - Unit of Work 和 Spring 事务冲突:Unit of Work 内部不加
@Transactional,由外层 Service 统一加。
面试常问
- 事务脚本和领域模型什么区别? 答:事务脚本是过程式,一个方法一个事务,业务规则散落;领域模型是面向对象,业务规则内聚在对象里,Service 只协调。判题平台核心域用领域模型,边缘域用事务脚本。
- Identity Map 解决什么问题? 答:解决同一请求内重复查询同一对象的问题(N+1)。判题平台查提交列表时,同一用户的多个提交只查一次用户表。
- Unit of Work 和
@Transactional怎么配合? 答:Unit of Work 收集变更,外层 Service 加@Transactional统一提交。Unit 内部不管理事务,只管理变更列表。
自测三问
- 判题平台"提交+判题+更新记录"三步,用事务脚本和领域模型分别怎么写?
- 查"某课程提交列表"时,同一用户出现 5 次,怎么避免查 5 次用户表?
- 一次操作要改 Submission、PracticeRecord、UserStat 三张表,怎么保证要么全成功要么全失败?
小结一句话
PoEAA 是企业应用的"组织代码"方式:事务脚本管简单业务,领域模型管复杂业务,Identity Map 防重复查询,Unit of Work 保事务完整。
延伸
- 《企业应用架构模式》Martin Fowler
- 《领域驱动设计》Eric Evans,第 4 章
- Spring 事务管理
- 下一课:综合架构练习——C4 模型 + ADR 实战。