大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
综合练习与 dsa-java 分工
这一课你将能做到
- 独立完成一个 200 行以内的「用户管理系统」控制台项目,串起类型、集合、OOP、异常、IO 五大知识块
- 对照检查清单自评 13 课掌握度,明确薄弱项回去补哪一课
- 说清本课程与 dsa-java(算法路线)的分工边界,知道下一步进 Spring Boot 路线该带什么行囊
为什么要学
前面的课都是「单点技能」:一个知识点一个 Demo。真实工程是技能的组合:一个方法里同时有类型判断、集合操作、异常处理、对象设计。单点会 ≠ 组合会——中间隔着「面对需求不知道从哪下手」这道坎。
这一课用一个小项目打通任督二脉。场景选「用户管理」,因为它是本站后端的真实缩影:有实体(User)、有存储(Map 内存模拟 → 文件持久化模拟 MySQL)、有校验(异常)、有增删改查(Service 层逻辑)。
同时划清一个重要边界:语法课不负责刷题。链表反转、二叉树遍历、动态规划,请去 dsa-java 路线——两边目标不同,混着学两头空。
先建立图像
项目的分层雏形(Spring 后端三层架构的控制台极简版):
main(入口/菜单循环) ← 相当于 Controller:接收指令、分发
│
UserManager(业务层) ← 相当于 Service:增删改查规则
│
Map<Long, User>(内存存储) ← 相当于数据库
│
UserFileStore(持久层) ← 相当于 MySQL:读写 kv-store.txt 落盘
退出前落盘、启动时加载 → 程序重启数据不丢(这就是「持久化」的直觉)
跟着做
步骤 1:定义实体与业务异常
新建 UserApp.java(单文件多类,java UserApp.java 直跑):
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
// 实体:不可变属性少、可变状态多,用传统类;字段校验走 setter
class User {
private long id;
private String name;
private int age;
public User(long id, String name, int age) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("用户名不能为空");
}
this.id = id;
this.name = name;
setAge(age);
}
public long getId() { return id; }
public String getName() { return name; }
public int getAge() { return age; }
public void setAge(int age) {
if (age < 0 || age > 150) throw new IllegalArgumentException("年龄非法: " + age);
this.age = age;
}
@Override
public String toString() {
return "User{id=%d, name=%s, age=%d}".formatted(id, name, age);
}
}
// 业务异常:继承 RuntimeException,带错误码(呼应第 11 课)
class UserNotFoundException extends RuntimeException {
public UserNotFoundException(long id) {
super("用户不存在: id=" + id);
}
}
class DuplicateUserException extends RuntimeException {
public DuplicateUserException(String name) {
super("用户名已存在: " + name);
}
}
步骤 2:持久层——文件存储
// 继续加到 UserApp.java 里
class UserFileStore {
private final Path file = Path.of("users.txt");
// 格式约定:每行 id,name,age(简易 CSV)
void save(Map<Long, User> users) {
StringBuilder sb = new StringBuilder();
users.values().forEach(u ->
sb.append(u.getId()).append(",")
.append(u.getName()).append(",")
.append(u.getAge()).append("\n"));
try {
Files.writeString(file, sb.toString());
} catch (IOException e) {
throw new RuntimeException("落盘失败", e); // 包装受检异常为非受检上抛
}
}
Map<Long, User> load() {
Map<Long, User> users = new HashMap<>();
if (Files.notExists(file)) return users;
try (var lines = Files.lines(file)) {
lines.forEach(line -> {
if (line.isBlank()) return; // 跳过空行
String[] p = line.split(",");
users.put(Long.parseLong(p[0]),
new User(Long.parseLong(p[0]), p[1], Integer.parseInt(p[2])));
});
} catch (IOException e) {
throw new RuntimeException("加载失败", e);
}
return users;
}
}
步骤 3:业务层——增删改查
class UserManager {
private final Map<Long, User> users = new HashMap<>();
private final UserFileStore store = new UserFileStore();
private long nextId = 1;
public UserManager() {
users.putAll(store.load()); // 启动加载已有数据
nextId = users.keySet().stream().max(Long::compare).orElse(0L) + 1;
}
public User add(String name, int age) {
boolean exists = users.values().stream().anyMatch(u -> u.getName().equals(name));
if (exists) throw new DuplicateUserException(name);
User u = new User(nextId++, name, age);
users.put(u.getId(), u);
store.save(users); // 每次变更即落盘
return u;
}
public User get(long id) {
User u = users.get(id);
if (u == null) throw new UserNotFoundException(id);
return u;
}
public List<User> list() {
// 按 id 排序输出:HashMap 无序,转 List 再排
return users.values().stream()
.sorted(Comparator.comparingLong(User::getId))
.toList();
}
public void updateAge(long id, int newAge) {
get(id).setAge(newAge); // get 顺带做了存在性校验
store.save(users);
}
public void remove(long id) {
if (users.remove(id) == null) throw new UserNotFoundException(id);
store.save(users);
}
}
步骤 4:入口层——菜单循环
public class UserApp {
public static void main(String[] args) {
UserManager manager = new UserManager();
Scanner sc = new Scanner(System.in);
System.out.println("=== 用户管理系统 (help 查看命令) ===");
while (true) {
System.out.print("> ");
String cmd = sc.nextLine().strip();
try {
switch (cmd) {
case "" -> { /* 空输入跳过 */ }
case "help" -> System.out.println(
"add <名字> <年龄> | get <id> | list | age <id> <新年龄> | del <id> | exit");
case "list" -> manager.list().forEach(System.out::println);
case "exit" -> {
System.out.println("再见!");
return;
}
default -> handle(manager, cmd);
}
} catch (UserNotFoundException | DuplicateUserException e) {
System.out.println("业务提示: " + e.getMessage()); // 已知业务错误,友好提示
} catch (NumberFormatException e) {
System.out.println("参数应为数字: " + e.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("输入不合法: " + e.getMessage());
}
}
}
static void handle(UserManager m, String input) {
String[] p = input.split("\\s+");
switch (p[0]) {
case "add" -> System.out.println("已添加: " + m.add(p[1], Integer.parseInt(p[2])));
case "get" -> System.out.println(m.get(Long.parseLong(p[1])));
case "age" -> { m.updateAge(Long.parseLong(p[1]), Integer.parseInt(p[2])); System.out.println("已更新"); }
case "del" -> { m.remove(Long.parseLong(p[1])); System.out.println("已删除"); }
default -> System.out.println("未知命令,输入 help 查看");
}
}
}
运行效果:
> add tom 20
已添加: User{id=1, name=tom, age=20}
> add jerry 22
已添加: User{id=2, name=jerry, age=22}
> add tom 30
业务提示: 用户名已存在: tom
> list
User{id=1, name=tom, age=20}
User{id=2, name=jerry, age=22}
> exit
(重启程序后 > list 依然能列出两条 —— 落盘生效)
步骤 5:改造挑战(自选,验证迁移能力)
按顺序尝试,每一条都对应一个「将来 Spring 里会怎么写」的映射:
- 去掉
users.txt,换成 MySQL 时代的思考:UserManager的方法签名不变,只换存储实现——体会「面向接口编程」:先抽UserRepository接口,再写FileUserRepository实现 - 给
age命令加防并发:思考两个线程同时 add 会怎样(HashMap非线程安全),读读ConcurrentHashMap的文档 - 把 CSV 换成 JSON:用 Maven 建
maven-demo工程(上一课),引入 gson 依赖,toJson/fromJson重写落盘
步骤 6:全课程自评清单
逐条自测,答不上来的标注回去补哪课:
| 能力点 | 判据 | 补课 |
|---|---|---|
| 编译运行体系 | 说清 javac/java/JVM 关系,能跑通编译产物 | 第 1 课 |
| 语法基础 | 逐词解释 main 签名,包/import/命名不出错 | 第 2 课 |
| 类型系统 | 说出 Integer 缓存陷阱与金额精度方案 | 第 3 课 |
| 控制流 | 手写九九乘法表和箭头 switch | 第 4 课 |
| 方法 | 用代码证明 Java 只有值传递 | 第 5 课 |
| 数组 | Arrays.toString/sort/copyOf 用法条件反射 | 第 6 课 |
| OOP | 手写带校验 setter 的实体类 + record | 第 7 课 |
| 继承多态 | 解释 @Override 的意义和动态绑定 | 第 8 课 |
| 集合 | Map 计数、removeIf、HashMap 原理一段话 | 第 9 课 |
| 字符串 | == vs equals 满分作答 + StringBuilder 时机 | 第 10 课 |
| 异常 | 受检/非受检边界 + 自定义业务异常 | 第 11 课 |
| IO | 三行读写 + try-with-resources + 编码 | 第 12 课 |
| Maven | 读懂 pom、跑通 package、说出 starter 机制 | 第 13 课 |
步骤 7:与 dsa-java 的分工
| 维度 | java-fundamentals(本课) | dsa-java |
|---|---|---|
| 目标 | 会用 Java 写业务代码 | 会用 Java 解算法题 |
| 内容重点 | 语法、OOP、集合 API、异常、Maven | 链表/树/图、排序/查找、双指针、DP |
| 集合侧重 | ArrayList/HashMap 怎么用 | 底层结构复杂度分析与手写实现 |
| 代码形态 | 完整工程、分层、可维护 | 单文件刷题风格、重正确性与复杂度 |
| 学习节奏 | 看懂 → 抄写 → 独立写小系统 | 一题多解 → 复杂度优化 → 限时 |
实践约定:两线并行时,先花 30 分钟在本路线补语法,再进 dsa-java 刷题。刷题碰到「Java 语法卡壳」(比如没见过 Deque 的 API),回本路线对应课程补,不要在算法题里硬啃语法。
讲清楚要点
- 小项目检验的不是记忆而是组合:需求来了先分层(入口/业务/存储/实体),再逐层实现——这个思维顺序在 Spring 开发里一模一样。
- 「存储可替换」是分层的全部意义:今天文件、明天 MySQL,业务层代码不动。第 1 个改造挑战就是提前体验依赖注入。
- 异常分层处理:业务层抛语义异常(UserNotFoundException),入口层统一接住翻译成人话——Spring 的 @RestControllerAdvice 同款思想。
- 持久化的直觉:
程序退出内存清空、文件还在——重启后加载文件恢复状态。Redis 的 RDB 快照、MySQL 的数据文件,本质都是这个模型的工程化加强版。 - 查 API 的能力比背 API 重要:
stream().sorted(...)、Files.lines这类方法记不全很正常,会查 Javadoc、会在 IDE 里看方法签名,才是长期生产力。
你可能会懵的地方
-
这个项目和真实 Spring 后端差距多大? 形似度很高:分层、实体、异常、持久化全对应。差距在「连接层」——HTTP 请求如何变成方法调用(DispatcherServlet)、SQL 如何执行(MyBatis/JPA)、事务如何保证。这些是 Spring Boot 路线的内容,本项目的每个类到时候都能找到对应位置。
-
为什么要自己实现一遍,直接学 Spring 不是更快? 快是错觉。不懂 Map 就学 Redis 客户端、不懂接口多态就学 @Autowired、不懂异常就学全局处理器——全是在背咒语。基础课的作用就是让框架课「每一步都有根基」。
-
代码里用了 stream(),前面课没细讲? Stream API 是 JDK 8 的数据处理语法糖(filter/map/sorted/collect),属于「应该会用」级别。这里的用法看注释能懂即可;系统学习建议在学完集合后按需补充,不必现在深入。
-
感觉都学过但都记不住,正常吗? 正常且必然。编程能力 = 知识 × 练习次数。现在你缺的只是练习量:把这个小项目默写第二遍、第三遍,比再看三遍视频有效。自评清单低于 80% 的项,优先回去补对应课的「跟着做」。
-
下一步先学 dsa-java 还是 Spring Boot? 看目标:求职笔试/面试算法 → dsa-java 优先;尽快做出能跑的 Web 项目 → Spring Boot 优先,边做边补算法。两条线不冲突,顺序由你的时间表决定。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 所有代码塞进 main 方法 | 三百行无法维护 | 分层拆类:实体/业务/存储/入口 |
| 存储层直接被 main 调用 | 跳过业务层,校验无处安放 | main → Manager → Store 单向依赖 |
| catch (Exception e) 包住整个 main | 程序错得莫名其妙 | 按异常类型分级 catch,业务异常单独提示 |
| 每次 add 都重写整个文件觉得浪费 | 小数据量完全可接受;先跑通再优化 | 记住 Premature Optimization 教训 |
| 刷题时用本课思路写完整工程 | dsa-java 场景讲究快 | 两套习惯分开:算法题单文件简洁为主 |
| 学完不写项目直接开 Spring | 框架照抄,报错全懵 | 至少独立完成本项目一次(不看源码) |
卡点急救
- 运行报「找不到或无法加载主类 UserApp」:文件名与 public 类名不一致,或没在文件所在目录运行。单文件直跑
java UserApp.java。 - 重启后数据没恢复:检查
users.txt是否生成在工作目录(运行命令的目录);load 的 split 解析是否与 save 的格式一致(逗号、行序)。 - add 带空格的名字报数组越界:
split("\\s+")对空输入产生意外长度,入口处先p.length校验参数个数。 - NumberFormatException 反复出现:用户输入混入了非数字。已在 main 统一 catch,检查命令拼写(
age 1 20三个词一个不能少)。 - 中文在文件里乱码:Windows 记事本默认编码问题;代码里显式
Files.writeString(file, sb.toString(), StandardCharsets.UTF_8),读取同理。
面试常问
-
你的小项目怎么设计的?为什么这么分层?(简历项目题的开场变体) 入口负责交互、业务层封装规则、存储层负责持久化、实体承载数据;层间单向依赖。收益:存储可替换(文件→MySQL)、异常可统一处理、每层可独立测试。
-
怎么把文件存储换成 MySQL 而不改业务代码? 抽象
UserRepository接口,业务层只依赖接口;文件实现与数据库实现都实现该接口,装配处切换实现类。这就是面向接口编程 + 依赖注入的雏形。 -
HashMap 非线程安全,你的程序会不会有问题? 单线程控制台程序无碍。多线程环境(Web 服务天然多线程)用 ConcurrentHashMap,或对临界区加锁;这也是 Spring Bean 默认单例时要求成员无状态的原因。
-
如果数据量到了千万级,这个方案哪里先崩? 全量 load 进内存(OOM)、每次变更全量落盘(IO 风暴)、线性查找(遍历 O(n))。对应生产解法:数据库索引查找、增量持久化/事务日志、缓存热点数据——正好引出 MySQL + Redis 的分工。
自测三问
- 不看源码,画出「用户管理系统」的四个部分及依赖方向。
- 业务异常为什么继承 RuntimeException?如果继承 Exception,调用链会发生什么?
- 本课程和 dsa-java 各自解决什么问题?你接下来 4 周怎么分配两边的时间?
小结一句话
用一个小系统把 13 课知识拧成整体:分层、面向接口、异常分级、持久化直觉——这套骨架就是 Spring 后端的雏形;语法到此收官,算法去 dsa-java,Web 去下一站 Spring Boot。
延伸
- Oracle:Java 官方教程总目录
- 本站算法路线 dsa-java(两线并行,先补语法再刷题)
- 下一站:Spring Boot 快速上手——用
@RestController把今天的 UserManager 暴露成 HTTP API。