大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
综合练习
这一课你将能做到
- 独立实现「record DTO + enum 状态 + Set 去重 + Queue 发布队列 + Builder」的完整小模块,
java一条命令跑通 - 用 JUnit 5 写出覆盖重复 slug、FIFO 出队、非法参数的单元测试,并说出每条测试在保护什么
- 完成本 track 自检清单,把领域模型对接 Spring Boot 的下一步路线落到纸面
为什么要学
前 11 课的知识点都是「单点」,企业代码考验的是「组合」:一个课时目录模块同时用到 enum(状态)、record(DTO)、Map/Set(唯一性与顺序)、Queue(异步发布模拟)、Builder(构造)、异常(脏数据拦截)。自己从头敲一遍、再用测试锁住行为,才算把这些单点焊成能力。
这也是进入 Spring Boot 实战 前的最佳验收:本课的 TrackCatalog 在 sb-ioc 课会变成被容器托管的 Bean,DTO 会直接变成 REST 响应体——同一个模型,两种宿主。
先建立图像
模块结构(对应知识点标注):
TrackCatalogApp
├─ LessonStatus enum(第 2 课):DRAFT / PUBLISHED / ARCHIVED
├─ LessonView record + 紧凑构造器(第 6 课):不可变 DTO
├─ LessonBuilder 方法链(第 5 课):链式创建 + build 校验
└─ TrackCatalog
├─ Map<String,LessonView> slug 唯一索引(putIfAbsent,第 3/4 课)
├─ Queue<LessonView> 发布队列(ArrayDeque,FIFO)
├─ add() 重复 slug 返回 false
├─ enqueuePublish() 校验存在性与状态
└─ drainOne() 出队 → 状态流转 → 回写 Map
跟着做
步骤 1:实现完整模块(单文件可运行)
新建 TrackCatalogApp.java,完整可运行:
import java.util.ArrayDeque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.HashSet;
public class TrackCatalogApp {
enum LessonStatus { DRAFT, PUBLISHED, ARCHIVED }
record LessonView(String slug, String title, int orderIndex, LessonStatus status) {
LessonView {
if (slug == null || slug.isBlank())
throw new IllegalArgumentException("slug 不能为空");
if (orderIndex < 0)
throw new IllegalArgumentException("orderIndex 不能为负");
}
LessonView publish() {
return new LessonView(slug, title, orderIndex, LessonStatus.PUBLISHED);
}
}
static class LessonBuilder {
private String slug;
private String title = "";
private int orderIndex;
private LessonStatus status = LessonStatus.DRAFT;
LessonBuilder slug(String v) { this.slug = v; return this; }
LessonBuilder title(String v) { this.title = v; return this; }
LessonBuilder order(int v) { this.orderIndex = v; return this; }
LessonBuilder status(LessonStatus v) { this.status = v; return this; }
LessonView build() {
return new LessonView(slug, title, orderIndex, status);
}
}
static class TrackCatalog {
private final Map<String, LessonView> lessons = new LinkedHashMap<>(); // 保留插入序
private final Queue<LessonView> publishQueue = new ArrayDeque<>();
/** slug 唯一:重复返回 false,不让脏数据进来 */
boolean add(LessonView lesson) {
return lessons.putIfAbsent(lesson.slug(), lesson) == null;
}
boolean enqueuePublish(String slug) {
LessonView lesson = lessons.get(slug);
if (lesson == null || lesson.status() != LessonStatus.DRAFT) return false;
return publishQueue.offer(lesson);
}
/** 模拟异步发布:出队 → 状态流转 → 回写目录 */
LessonView drainOne() {
LessonView queued = publishQueue.poll();
if (queued == null) return null;
LessonView published = queued.publish();
lessons.put(published.slug(), published);
return published;
}
int size() { return lessons.size(); }
Set<String> slugs() { return new HashSet<>(lessons.keySet()); }
}
public static void main(String[] args) {
TrackCatalog catalog = new TrackCatalog();
catalog.add(new LessonBuilder().slug("jva-intro").title("Java 进阶概览").order(1).build());
catalog.add(new LessonBuilder().slug("jva-enums").title("枚举与模式").order(2).build());
boolean dup = catalog.add(new LessonBuilder().slug("jva-intro").title("重复").order(9).build());
System.out.println("重复 slug 添加结果:" + dup); // false
catalog.enqueuePublish("jva-intro");
catalog.enqueuePublish("jva-enums");
catalog.enqueuePublish("不存在"); // 静默失败,返回 false
LessonView published;
while ((published = catalog.drainOne()) != null) {
System.out.println("已发布:" + published.slug() + " -> " + published.status());
}
System.out.println("目录大小:" + catalog.size() + ",slug 集合:" + catalog.slugs());
}
}
运行:java TrackCatalogApp.java。逐行核对输出:重复添加是 false、发布按 FIFO 顺序、状态从 DRAFT 流转到 PUBLISHED。
步骤 2:JUnit 5 锁住行为
Maven 项目(mvn -q archetype:generate 或直接建 pom.xml 引入 junit-jupiter 5.10+)中,把上面的类移到 src/main/java,测试放 src/test/java 同包:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TrackCatalogTest {
private TrackCatalogApp.TrackCatalog catalog;
@BeforeEach
void setUp() {
catalog = new TrackCatalogApp.TrackCatalog();
catalog.add(new TrackCatalogApp.LessonBuilder()
.slug("a").title("A").order(1).build());
}
@Test
void duplicateSlugShouldBeRejected() {
assertFalse(catalog.add(new TrackCatalogApp.LessonBuilder()
.slug("a").title("A2").order(2).build()));
assertEquals(1, catalog.size()); // 目录未被污染
}
@Test
void publishShouldDrainInFifoOrder() {
catalog.add(new TrackCatalogApp.LessonBuilder()
.slug("b").title("B").order(2).build());
catalog.enqueuePublish("a");
catalog.enqueuePublish("b");
assertEquals("a", catalog.drainOne().slug()); // 先进先出
assertEquals("b", catalog.drainOne().slug());
assertNull(catalog.drainOne()); // 队列空了
}
@Test
void publishedLessonCannotEnqueueAgain() {
catalog.enqueuePublish("a");
catalog.drainOne(); // 已发布
assertFalse(catalog.enqueuePublish("a")); // 状态机拒绝二次发布
}
@Test
void blankSlugShouldThrow() {
assertThrows(IllegalArgumentException.class, () -> new TrackCatalogApp.LessonBuilder()
.slug(" ").title("x").order(1).build());
}
}
运行 mvn test,四条全绿。每条测试对应一条业务规则——这就是「测试即文档」。
步骤 3:对照「如果交给 Spring」做映射推演
纸笔完成,不需要代码:
TrackCatalog → @Service Bean(单例,无请求级状态,线程安全吗?本例读写 Map 需 ConcurrentHashMap)
LessonView → REST 响应体(record 直接返回)
enqueuePublish → POST /lessons/{slug}/publish
drainOne 循环 → Spring @Async + 线程池(第 8 课)或 MQ 真异步
blankSlug 校验 → Bean Validation @NotBlank + 全局异常处理
讲清楚要点
- 组合优先于堆功能:这个 100 行模块用到了 6 课的知识点,但没有一行是为炫技而写——每个结构都在解决一个真实问题(唯一性、顺序、状态、不可变)。
- 校验放在边界:record 紧凑构造器拦非法值、
putIfAbsent拦重复 slug、enqueuePublish拦错误状态——脏数据在最外层死掉,内部逻辑就能假设一切合法。 - 不可变让状态机可推理:
LessonView.publish()返回新实例而不是改 status 字段,任何时刻看到的状态都是某个确定版本,多线程下也无竞态。 - 测试保护的是规则:重复拒绝、FIFO、不可二次发布、空 slug 抛异常——每条规则一个测试;需求变了先改测试再改实现,行为不会悄悄漂移。
- 纯 Java 模块是框架的地基:本模块不依赖 Spring 也能完整工作;框架接管的是「怎么被发现、怎么被调用」,业务核心保持 POJO 才可测可移植。
你可能会懵的地方
- 为什么用 LinkedHashMap 而不是 HashMap?
目录有「按添加顺序展示」的语义;HashMap 不保证顺序。若还要求按 orderIndex 排序输出,可在展示层
values().stream().sorted(comparing(LessonView::orderIndex))。 - drainOne 返回 null 合理吗?
这是教学取轻。工程做法返回
Optional<LessonView>强迫调用方处理空值;队列耗尽在真实系统里由BlockingQueue.take()阻塞等待替代轮询。 - add 返回 boolean 还是抛异常?
「重复 slug」在本模型里是预期内的可恢复情况,返回 false 合理;若调用方重复是程序 bug,抛
IllegalArgumentException更能暴露问题。判断标准:调用方能不能也该不该处理这种情况。 - 发布队列和 MQ 什么关系? 内存 Queue 进程一挂就丢,且只在本进程内流转;MQ(如 RabbitMQ/Kafka)提供持久化、跨服务投递与重试。本课的 Queue 让你先掌握「队列语义」,MQ 是它的分布式形态。
- 下一步该先补什么? 两条线按需选:直接进 spring-boot-essentials 把本模型挂上 HTTP 与 JPA;或进 dsa-java 用同一批集合知识刷算法。语言层缺口(如 IO、JVM)在实战中按需回补。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 用 List 存课时 + contains 查重 | O(n) 且语义模糊 | Map/Set 做唯一索引 |
| 直接改 LessonView 的 status 字段 | record 没有 setter;可变状态难推理 | 返回新实例的状态流转方法 |
| enqueue 不校验存在性与状态 | 脏数据进队列,发布时爆炸 | 边界校验 + 状态机拒绝 |
| 只写实现不写测试 | 重构无从下手 | 规则 → 测试 → 实现 |
| 一次写完 200 行再跑 | 错误定位成本高 | 小步跑通:先 record,再 add,再队列 |
卡点急救
- 测试里访问不到 TrackCatalog:类是包级私有静态嵌套类,测试类必须与
TrackCatalogApp同包同源码集;或把业务类提为独立文件加 public。 - mvn test 找不到 JUnit:
pom.xml确认引入junit-jupiter(test scope)并配置 surefire 3.x;Spring Boot 项目用spring-boot-starter-test一步到位。 - FIFO 测试偶发失败:检查是否误用了
Stack语义(push/pop)或 PriorityQueue;发布顺序语义要求严格 FIFO 时,ArrayDeque 的 offer/poll 才是正解。
面试常问
- 让你设计一个「课程目录」模块,你怎么入手? 答题框架:先定领域模型(不可变 record DTO + enum 状态机)→ 定不变量(slug 唯一、状态只能 DRAFT→PUBLISHED)→ 选数据结构(Map 索引唯一性、LinkedHashMap 保序、Queue 模拟异步)→ 定边界(构造器/工厂校验)→ 定测试(每条不变量一条用例)。展示的是「先约束后实现」的工程思维。
- 为什么业务核心类不依赖框架注解更好? 可单测(不启动容器)、可移植(换框架不重写)、职责清晰(框架管生命周期与装配,业务管规则);Spring 自己的官方建议也是「POJO + 少量注解」,注解只在组装层出现。
- 内存队列和 MQ 的取舍? 内存队列:零依赖、快、但不可持久化且限单进程,适合进程内解耦;MQ:持久化、跨服务、可重试削峰,但引入运维成本与一致性复杂度。按「数据丢了能不能接受、上下游是否同进程」两问决策。
自测三问
- 本模块里哪三处分别拦截了「重复 slug、非法参数、非法状态」?
putIfAbsent与put的返回值差异是什么?为什么去重要用前者?- 把 TrackCatalog 交给 Spring 后,哪两处线程安全隐患需要处理?
小结一句话
枚举做状态、record 做数据、Map/Set 保唯一、Queue 做异步、Builder 造对象、测试锁行为——这 100 行就是进阶 Java 的合格答卷。
延伸
- dev.java 官方学习路径
- Oracle JDK 21 文档
- 下一步:进入 Spring Boot 实战,把本课的 TrackCatalog 与 LessonView 挂到真实 HTTP 接口、MySQL 与 Redis 缓存上,走完整的 Nginx → Boot → MySQL 链路;之后可选 Spring Cloud 微服务 或用 dsa-java 巩固算法。