控制台学生管理系统 | JavaSE
控制台学生管理系统
一、项目目标
前面学习 JavaSE 时,我们一直在解决一个个相对独立的问题:
Student类怎么定义?ArrayList怎么保存对象?Stream怎么过滤、排序和统计?- 异常怎么处理?
- File 与 IO 怎么读写文件?
- 方法和类应该怎么拆分?
而真正的软件开发不是:
今天写一个 ArrayList,明天写一个 IO。
而是:
为了完成一个业务需求,把多个技术组织成一个完整系统。
因此,这个学生管理系统的核心目标并不是“再练一次增删改查”,而是完成第一次真正意义上的 JavaSE 综合工程训练。
完成本项目后,应能够:
- 根据需求分析系统应该有哪些功能。
- 根据业务对象设计实体类。
- 使用集合保存和管理对象。
- 实现学生信息的 CRUD。
- 实现条件查询、排序、去重和统计。
- 使用异常处理业务错误。
- 使用 IO 将学生数据持久化到磁盘。
- 将程序拆分为不同职责的类。
- 理解最基础的分层设计思想。
- 独立完成一个规模达到数百行代码的 JavaSE 程序。
这里尤其重要的是第 8 点:
从“我会写代码”开始向“我会组织代码”过渡。
二、需求分析
2.1 项目背景
学校需要一个简单的学生信息管理程序。
管理员启动程序后,可以通过控制台菜单管理学生。
例如:
====================================
星雨学生信息管理系统
====================================
1. 添加学生
2. 查看全部学生
3. 根据学号查询
4. 修改学生
5. 删除学生
6. 按姓名搜索
7. 按成绩排序
8. 学生成绩统计
0. 退出系统
====================================
请选择:
2.2 学生信息
每个学生至少包含:
| 字段 | 类型 | 示例 |
| ---- | ------ | ------------- |
| 学号 | String | 20260001 |
| 姓名 | String | 张三 |
| 年龄 | int | 20 |
| 性别 | String | 男 |
| 班级 | String | 软件工程1班 |
| 成绩 | double | 89.5 |
其中:
学号
作为学生的唯一标识。
即:
20260001 张三
20260001 李四
不允许同时存在。
这实际上就是数据库中:
PRIMARY KEY
思想的最简单雏形。
2.3 基础 CRUD
CRUD 是开发中极其重要的一组缩写:
| 英文 | 含义 | 本项目 | | ------ | ---- | -------- | | Create | 创建 | 添加学生 | | Read | 查询 | 查看学生 | | Update | 修改 | 修改学生 | | Delete | 删除 | 删除学生 |
因此第一阶段必须实现:
添加学生
查询学生
修改学生
删除学生
2.4 查询需求
除了普通 CRUD,还需要:
根据学号精确查询
输入:
20260001
输出:
20260001 张三 20 男 软件工程1班 89.5
根据姓名模糊查询
例如系统存在:
张三
张三丰
李四
搜索:
张
得到:
张三
张三丰
2.5 排序需求
支持:
按成绩从高到低显示学生。
若成绩相同,则按照学号排序。
例如:
95.0 20260001
95.0 20260003
89.5 20260002
2.6 统计需求
至少完成:
学生总人数
学生总人数:6
平均成绩
平均成绩:86.75
最高成绩
最高成绩:98.0
最低成绩
最低成绩:67.5
各班人数
软件工程1班:3人
软件工程2班:2人
计算机1班:1人
这会综合训练:
Stream
mapToDouble()
average()
max()
min()
Collectors.groupingBy()
2.7 数据持久化需求
如果只使用:
List<Student> students = new ArrayList<>();
程序关闭以后:
students
中的数据就全部消失。
因此要求:
学生数据必须能够保存到本地文件。
下一次启动程序时:
文件
↓
读取
↓
List<Student>
程序退出或者数据发生修改时:
List<Student>
↓
写入
↓
文件
于是整个数据生命周期成为:
磁盘文件
↓
程序启动
↓
读取数据
↓
内存集合
↓
业务操作
↓
保存数据
↓
磁盘文件
这就是最简单的数据持久化(Persistence)。
三、技术选型
这个项目故意 不使用 MySQL、不使用 JDBC、不使用 Spring Boot。
因为我们现在验证的是 JavaSE。
3.1 技术栈
JDK 21
Scanner
面向对象
ArrayList
Stream API
Comparator
Map
异常
File / Path
DataInputStream
DataOutputStream
BufferedInputStream
BufferedOutputStream
3.2 为什么使用 ArrayList
学生需要:
批量保存
顺序遍历
显示所有学生
排序
Stream处理
因此:
List<Student>
非常合适。
具体实现:
new ArrayList<>()
3.3 为什么学号使用 String
不要因为学号看起来全是数字,就直接设计成:
int id;
例如:
001234
若保存为整数:
1234
前面的 00 会消失。
而学号本质上并不是拿来做数学运算的数字。
所以应该:
String id;
类似的还有:
身份证号
手机号
银行卡号
订单号
快递单号
很多“看起来像数字”的业务数据,本质上都是字符串。
3.4 为什么暂时不用 HashMap 作为唯一数据源
我们当然可以设计:
Map<String, Student>
其中:
key = 学号
value = Student
这样按 ID 查询速度非常快。
但本项目第一版采用:
List<Student>
作为主要存储结构。
原因是我们现在希望同时练习:
集合遍历
Stream
Comparator
CRUD
后面的项目扩展中再加入:
HashMap<String, Student>
建立索引。
这与数据库中的:
表数据 + 索引
思想非常接近。
四、系统设计
4.1 不要把所有代码写进 main
最差的一种实现方式就是:
public static void main(String[] args) {
// 菜单
// 添加学生
// 查询学生
// 修改学生
// 删除学生
// 文件读取
// 文件保存
// 数据统计
}
最终很可能得到:
main()
└── 500 行代码
程序虽然可能运行,但已经非常难维护。
4.2 最基础的分层设计
我们把系统拆成:
StudentApplication
↓
StudentService
↓
StudentRepository
↓
students.dat
再加上:
Student
实体对象。
整体关系:
┌───────────────────────────┐
│ StudentApplication │
│ 控制台交互、菜单、输入输出 │
└─────────────┬─────────────┘
↓
┌───────────────────────────┐
│ StudentService │
│ 业务规则 │
│ CRUD / 查询 / 排序 / 统计 │
└─────────────┬─────────────┘
↓
┌───────────────────────────┐
│ StudentRepository │
│ 数据管理、文件持久化 │
└─────────────┬─────────────┘
↓
students.dat
这已经出现了未来 JavaWeb 中非常重要的结构思想:
Controller
Service
Mapper / Repository
Database
只不过现在:
控制台
代替了 Controller,
文件
代替了数据库。
4.3 包结构
推荐建立:
com.yulanlin.student
│
├── app
│ └── StudentApplication.java
│
├── model
│ └── Student.java
│
├── service
│ └── StudentService.java
│
├── repository
│ └── StudentRepository.java
│
└── exception
├── BusinessException.java
└── DataAccessException.java
这已经不是:
一个类完成所有事情
而是:
每一个类负责一类职责。
这叫:
单一职责原则(Single Responsibility Principle, SRP)。
4.4 系统调用流程
以“添加学生”为例:
用户
↓
StudentApplication
↓
读取输入
↓
创建 Student
↓
StudentService.addStudent()
↓
检查学号是否重复
检查年龄是否合法
检查成绩是否合法
↓
StudentRepository.add()
↓
ArrayList
↓
save()
↓
students.dat
注意:
Application 不负责:
学号重复判断
成绩合法性判断
这些属于:
Service
业务层。
五、核心功能实现
5.1 Student 实体类
package com.yulanlin.student.model;
public class Student {
private final String id;
private String name;
private int age;
private String gender;
private String className;
private double score;
public Student(
String id,
String name,
int age,
String gender,
String className,
double score) {
this.id = id;
this.name = name;
this.age = age;
this.gender = gender;
this.className = className;
this.score = score;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
public String getClassName() {
return className;
}
public void setClassName(String className) {
this.className = className;
}
public double getScore() {
return score;
}
public void setScore(double score) {
this.score = score;
}
@Override
public String toString() {
return "%-12s %-8s %-4d %-4s %-16s %.2f"
.formatted(
id,
name,
age,
gender,
className,
score
);
}
}
这里特意把:
id
声明成:
final
原因是:
学号是学生的身份标识,创建以后原则上不应该随意修改。
这属于非常重要的:
对象不变量(Object Invariant)
设计思想。
5.2 业务异常
当用户试图添加重复学号时,这不是 JVM 自己产生的错误。
这是:
业务规则不允许。
因此可以定义业务异常:
package com.yulanlin.student.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
例如:
throw new BusinessException("学号已经存在");
5.3 数据访问异常
文件读取失败与:
成绩不合法
不是同一类问题。
因此再设计:
package com.yulanlin.student.exception;
public class DataAccessException extends RuntimeException {
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
它专门描述:
文件读取失败
文件写入失败
数据文件损坏
5.4 Repository:数据仓库
package com.yulanlin.student.repository;
import com.yulanlin.student.exception.DataAccessException;
import com.yulanlin.student.model.Student;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class StudentRepository {
private final List<Student> students = new ArrayList<>();
private final Path dataFile;
public StudentRepository(Path dataFile) {
this.dataFile = dataFile;
load();
}
public List<Student> findAll() {
return List.copyOf(students);
}
public Optional<Student> findById(String id) {
return students.stream()
.filter(student -> student.getId().equals(id))
.findFirst();
}
public void add(Student student) {
students.add(student);
save();
}
public boolean removeById(String id) {
boolean removed =
students.removeIf(student ->
student.getId().equals(id));
if (removed) {
save();
}
return removed;
}
public void save() {
try {
Path parent = dataFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try (
DataOutputStream output =
new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(dataFile)
)
)
) {
output.writeInt(students.size());
for (Student student : students) {
output.writeUTF(student.getId());
output.writeUTF(student.getName());
output.writeInt(student.getAge());
output.writeUTF(student.getGender());
output.writeUTF(student.getClassName());
output.writeDouble(student.getScore());
}
}
} catch (IOException e) {
throw new DataAccessException(
"保存学生数据失败:" + dataFile,
e
);
}
}
private void load() {
if (Files.notExists(dataFile)) {
return;
}
try (
DataInputStream input =
new DataInputStream(
new BufferedInputStream(
Files.newInputStream(dataFile)
)
)
) {
int size = input.readInt();
if (size < 0) {
throw new IOException("非法学生数量:" + size);
}
for (int i = 0; i < size; i++) {
String id = input.readUTF();
String name = input.readUTF();
int age = input.readInt();
String gender = input.readUTF();
String className = input.readUTF();
double score = input.readDouble();
students.add(
new Student(
id,
name,
age,
gender,
className,
score
)
);
}
} catch (IOException e) {
throw new DataAccessException(
"读取学生数据失败:" + dataFile,
e
);
}
}
}
这里已经综合使用了大量 JavaSE:
ArrayList
Stream
Optional
Path
Files
DataInputStream
DataOutputStream
BufferedInputStream
BufferedOutputStream
try-with-resources
异常包装
5.5 为什么文件首先写学生数量
注意:
output.writeInt(students.size());
文件结构实际上类似:
学生数量
Student1
Student2
Student3
...
例如:
3
↓
张三
↓
李四
↓
王五
读取时:
int size = input.readInt();
for (int i = 0; i < size; i++) {
...
}
这样我们明确知道:
后面应该读取多少个 Student。
而不是使用一些不可靠的方式去“猜文件什么时候读完”。
5.6 StudentService
接下来实现真正的业务逻辑。
package com.yulanlin.student.service;
import com.yulanlin.student.exception.BusinessException;
import com.yulanlin.student.model.Student;
import com.yulanlin.student.repository.StudentRepository;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class StudentService {
private final StudentRepository repository;
public StudentService(StudentRepository repository) {
this.repository = repository;
}
public void addStudent(Student student) {
validateStudent(student);
if (repository.findById(student.getId()).isPresent()) {
throw new BusinessException(
"学号 " + student.getId() + " 已经存在"
);
}
repository.add(student);
}
public List<Student> findAll() {
return repository.findAll();
}
public Student findById(String id) {
return repository.findById(id)
.orElseThrow(() ->
new BusinessException(
"不存在学号为 " + id + " 的学生"
)
);
}
public void deleteStudent(String id) {
if (!repository.removeById(id)) {
throw new BusinessException(
"不存在学号为 " + id + " 的学生"
);
}
}
public void updateStudent(
String id,
String name,
int age,
String gender,
String className,
double score) {
Student student = findById(id);
Student newData =
new Student(
id,
name,
age,
gender,
className,
score
);
validateStudent(newData);
student.setName(name);
student.setAge(age);
student.setGender(gender);
student.setClassName(className);
student.setScore(score);
repository.save();
}
public List<Student> searchByName(String keyword) {
String normalizedKeyword =
keyword.toLowerCase(Locale.ROOT);
return repository.findAll()
.stream()
.filter(student ->
student.getName()
.toLowerCase(Locale.ROOT)
.contains(normalizedKeyword)
)
.toList();
}
public List<Student> sortByScoreDescending() {
return repository.findAll()
.stream()
.sorted(
Comparator
.comparingDouble(Student::getScore)
.reversed()
.thenComparing(Student::getId)
)
.toList();
}
public double averageScore() {
return repository.findAll()
.stream()
.mapToDouble(Student::getScore)
.average()
.orElse(0.0);
}
public double maxScore() {
return repository.findAll()
.stream()
.mapToDouble(Student::getScore)
.max()
.orElse(0.0);
}
public double minScore() {
return repository.findAll()
.stream()
.mapToDouble(Student::getScore)
.min()
.orElse(0.0);
}
public Map<String, Long> countByClass() {
return repository.findAll()
.stream()
.collect(
Collectors.groupingBy(
Student::getClassName,
TreeMap::new,
Collectors.counting()
)
);
}
private void validateStudent(Student student) {
if (student.getId() == null ||
student.getId().isBlank()) {
throw new BusinessException(
"学号不能为空"
);
}
if (student.getName() == null ||
student.getName().isBlank()) {
throw new BusinessException(
"姓名不能为空"
);
}
if (student.getAge() < 1 ||
student.getAge() > 150) {
throw new BusinessException(
"年龄必须位于 1~150"
);
}
if (student.getScore() < 0 ||
student.getScore() > 100) {
throw new BusinessException(
"成绩必须位于 0~100"
);
}
if (student.getClassName() == null ||
student.getClassName().isBlank()) {
throw new BusinessException(
"班级不能为空"
);
}
}
}
5.7 addStudent 为什么不直接 add
很多初学代码会写:
students.add(student);
看起来实现了添加。
但真正业务中的“添加”其实是:
检查参数
↓
检查唯一性
↓
添加
↓
保存
也就是:
validateStudent(student);
if (学号重复) {
报错;
}
repository.add(student);
这就是:
业务逻辑(Business Logic)。
5.8 StudentApplication
最后处理用户交互。
package com.yulanlin.student.app;
import com.yulanlin.student.exception.BusinessException;
import com.yulanlin.student.exception.DataAccessException;
import com.yulanlin.student.model.Student;
import com.yulanlin.student.repository.StudentRepository;
import com.yulanlin.student.service.StudentService;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
public class StudentApplication {
private final Scanner scanner = new Scanner(System.in);
private final StudentService service;
public StudentApplication(StudentService service) {
this.service = service;
}
public static void main(String[] args) {
StudentRepository repository =
new StudentRepository(
Path.of("data", "students.dat")
);
StudentService service =
new StudentService(repository);
new StudentApplication(service).run();
}
public void run() {
System.out.println("学生管理系统启动成功。");
while (true) {
printMenu();
String command =
scanner.nextLine().trim();
try {
switch (command) {
case "1" -> addStudent();
case "2" -> listStudents();
case "3" -> findStudent();
case "4" -> updateStudent();
case "5" -> deleteStudent();
case "6" -> searchStudent();
case "7" -> sortStudents();
case "8" -> showStatistics();
case "0" -> {
System.out.println("系统已退出。");
return;
}
default ->
System.out.println(
"请输入正确的菜单编号。"
);
}
} catch (BusinessException e) {
System.out.println(
"操作失败:" + e.getMessage()
);
} catch (DataAccessException e) {
System.out.println(
"数据文件操作失败:" + e.getMessage()
);
}
}
}
private void printMenu() {
System.out.println();
System.out.println(
"===================================="
);
System.out.println(
" 星雨学生信息管理系统"
);
System.out.println(
"===================================="
);
System.out.println("1. 添加学生");
System.out.println("2. 查看全部学生");
System.out.println("3. 根据学号查询");
System.out.println("4. 修改学生");
System.out.println("5. 删除学生");
System.out.println("6. 按姓名搜索");
System.out.println("7. 按成绩排序");
System.out.println("8. 学生成绩统计");
System.out.println("0. 退出系统");
System.out.print("请选择:");
}
private void addStudent() {
String id = readString("学号:");
String name = readString("姓名:");
int age = readInt("年龄:");
String gender = readString("性别:");
String className = readString("班级:");
double score = readDouble("成绩:");
Student student =
new Student(
id,
name,
age,
gender,
className,
score
);
service.addStudent(student);
System.out.println("添加成功。");
}
private void listStudents() {
printStudents(service.findAll());
}
private void findStudent() {
String id = readString("请输入学号:");
printHeader();
System.out.println(
service.findById(id)
);
}
private void updateStudent() {
String id = readString(
"请输入需要修改的学生学号:"
);
Student student = service.findById(id);
System.out.println("当前学生信息:");
System.out.println(student);
String name = readString("新姓名:");
int age = readInt("新年龄:");
String gender = readString("新性别:");
String className = readString("新班级:");
double score = readDouble("新成绩:");
service.updateStudent(
id,
name,
age,
gender,
className,
score
);
System.out.println("修改成功。");
}
private void deleteStudent() {
String id = readString(
"请输入需要删除的学号:"
);
service.deleteStudent(id);
System.out.println("删除成功。");
}
private void searchStudent() {
String keyword =
readString("请输入姓名关键字:");
printStudents(
service.searchByName(keyword)
);
}
private void sortStudents() {
printStudents(
service.sortByScoreDescending()
);
}
private void showStatistics() {
List<Student> students =
service.findAll();
System.out.println(
"学生总人数:" + students.size()
);
System.out.printf(
"平均成绩:%.2f%n",
service.averageScore()
);
System.out.printf(
"最高成绩:%.2f%n",
service.maxScore()
);
System.out.printf(
"最低成绩:%.2f%n",
service.minScore()
);
System.out.println("各班人数:");
for (
Map.Entry<String, Long> entry
: service.countByClass().entrySet()
) {
System.out.println(
entry.getKey()
+ ":"
+ entry.getValue()
+ " 人"
);
}
}
private void printStudents(
List<Student> students) {
if (students.isEmpty()) {
System.out.println(
"当前没有符合条件的学生。"
);
return;
}
printHeader();
students.forEach(
System.out::println
);
}
private void printHeader() {
System.out.printf(
"%-12s %-8s %-4s %-4s %-16s %s%n",
"学号",
"姓名",
"年龄",
"性别",
"班级",
"成绩"
);
}
private String readString(String prompt) {
while (true) {
System.out.print(prompt);
String value =
scanner.nextLine().trim();
if (!value.isEmpty()) {
return value;
}
System.out.println(
"输入不能为空。"
);
}
}
private int readInt(String prompt) {
while (true) {
System.out.print(prompt);
String value =
scanner.nextLine().trim();
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
System.out.println(
"请输入合法整数。"
);
}
}
}
private double readDouble(String prompt) {
while (true) {
System.out.print(prompt);
String value =
scanner.nextLine().trim();
try {
return Double.parseDouble(value);
} catch (NumberFormatException e) {
System.out.println(
"请输入合法数字。"
);
}
}
}
}
六、关键技术解析
6.1 为什么 Scanner 全部使用 nextLine
你以前已经遇到过:
nextInt()
nextLine()
混合使用的问题。
例如:
int age = scanner.nextInt();
String name = scanner.nextLine();
nextInt() 不会消费这一行最后的换行符,因此后面的:
nextLine()
可能直接读取剩余换行。
本项目采用:
scanner.nextLine()
读取所有输入。
数字则:
Integer.parseInt()
Double.parseDouble()
转换。
于是控制台输入统一成:
String
↓
解析
↓
业务类型
这种设计更容易控制输入错误。
6.2 为什么使用 Optional
Repository:
public Optional<Student> findById(String id)
表达的语义是:
可能找到
也可能找不到
而不是:
Student
然后偷偷返回:
null
Service 中:
repository.findById(id)
.orElseThrow(...)
直接把:
没有学生
转换成明确的业务异常。
6.3 为什么排序后的结果不直接修改原集合
代码:
repository.findAll()
.stream()
.sorted(...)
.toList();
得到的是一个新的结果视图。
我们只是:
按照成绩展示。
并没有业务要求:
永久修改系统中的学生存储顺序。
所以不要为了展示排序就破坏原始集合。
6.4 Comparator 链式排序
Comparator
.comparingDouble(Student::getScore)
.reversed()
.thenComparing(Student::getId)
理解为:
第一排序规则:
score 降序
如果 score 相同:
第二排序规则:
id 升序
即:
成绩 ↓
学号 ↑
6.5 groupingBy 本质是什么
Collectors.groupingBy(
Student::getClassName,
Collectors.counting()
)
本质就是:
Student
↓ getClassName()
班级
↓
分组
↓
统计数量
结果:
Map<String, Long>
例如:
软件工程1班 -> 3
软件工程2班 -> 5
计算机1班 -> 2
6.6 持久化与内存数据的区别
程序运行过程中:
List<Student>
位于:
内存
优点:
访问速度快
操作方便
缺点:
程序关闭后消失
而:
students.dat
位于磁盘。
优点:
程序关闭后仍然存在
所以:
集合负责运行时数据
文件负责持久化数据
这是以后学习数据库时非常重要的思想。
6.7 为什么程序已经有一点“三层架构”的味道
目前:
StudentApplication
StudentService
StudentRepository
分别负责:
交互
业务
数据
未来 JavaWeb 中会逐渐演化为:
Controller
↓
Service
↓
Mapper
↓
MySQL
甚至:
Browser
↓
Controller
↓
Service
↓
Mapper
↓
MySQL
所以你之后第一次学习 JavaWeb 三层架构时,不应该觉得它突然出现。
你现在已经见过它的原型。
七、测试与 Debug
一个项目写完:
能启动 ≠ 正确。
必须主动测试边界情况。
7.1 添加学生测试
正常情况
20260001
张三
20
男
软件工程1班
90
预期:
添加成功
重复学号
再次输入:
20260001
预期:
操作失败:学号 20260001 已经存在
非法年龄
输入:
-1
或:
200
预期:
年龄必须位于 1~150
非法成绩
输入:
101
预期:
成绩必须位于 0~100
7.2 查询测试
测试:
存在学号
不存在学号
空学号
例如:
999999
预期:
不存在学号为 999999 的学生
7.3 删除测试
重点测试:
删除存在学生
删除不存在学生
连续删除同一个学生
7.4 修改测试
需要检查:
姓名是否修改
年龄是否修改
班级是否修改
成绩是否修改
学号是否保持不变
磁盘文件是否同步更新
7.5 排序测试
构造:
A 90
B 100
C 90
D 60
结果应该:
B 100
A 90
C 90
D 60
同时验证:
同分情况下按照学号排序。
7.6 空集合统计
一个非常经典的边界问题:
系统没有任何学生。
此时:
average()
max()
min()
都会返回:
OptionalDouble
本项目:
.orElse(0.0)
因此不会抛异常。
7.7 文件持久化测试
完整测试流程:
启动程序
↓
添加 3 个学生
↓
退出程序
↓
确认 data/students.dat 存在
↓
重新启动
↓
查看全部学生
如果还能看到原来的学生:
持久化成功
7.8 数据文件损坏测试
可以:
- 先关闭程序。
- 手动修改
students.dat。 - 再次启动。
由于这是:
二进制文件
随意修改很可能导致:
EOFException
UTFDataFormatException
最终被包装为:
DataAccessException
这可以帮助理解:
文件存在,不代表文件内容一定合法。
八、项目扩展
基础版本完成以后,不要急着认为项目已经结束。
可以逐级升级。
8.1 V2:HashMap 索引
现在查询 ID:
students.stream()
.filter(...)
时间复杂度近似:
O(n)
可以增加:
Map<String, Student> studentIndex;
形成:
ArrayList
负责完整数据
HashMap
负责 ID 索引
查询:
studentIndex.get(id);
平均情况下可以接近:
O(1)
这正好训练:
List + Map
组合使用。
8.2 V3:Set 去重
可以增加:
Set<String> studentIds
专门保存所有学号。
判断重复:
studentIds.contains(id)
形成:
List<Student>
HashSet<String>
HashMap<String, Student>
分别解决:
顺序存储
唯一性
快速索引
这正是集合框架真正应该掌握的能力:
不是知道 API,而是知道什么时候选择什么数据结构。
8.3 V4:更多统计
增加:
成绩分段统计
班级平均分
班级最高分
及格率
优秀率
总成绩排行榜
班级排行榜
例如:
Map<String, Double>
保存:
班级 -> 平均成绩
8.4 V5:导出文本报告
把学生成绩报告输出:
student-report.txt
例如:
============================
学生成绩报告
============================
总人数:50
平均成绩:82.34
最高成绩:99.00
最低成绩:43.00
优秀:12人
良好:18人
及格:14人
不及格:6人
可以综合:
Stream
StringBuilder
BufferedWriter
8.5 V6:JUnit
为:
StudentService
编写测试:
正常添加
重复添加
非法成绩
查询不存在
删除不存在
成绩统计
排序
这就把前面学习的:
JUnit
真正放进一个项目。
8.6 V7:注解 + 反射
甚至可以自己设计:
@DisplayName("学号")
private final String id;
然后通过反射:
读取字段
↓
读取注解
↓
自动生成表头
此时你会发现:
反射和注解并不是为了“学一个高级 API”,而是为了让程序能够根据元数据自动工作。
8.7 V8:动态代理
为 Service 创建:
StudentService
↓
Proxy
↓
日志
耗时统计
异常记录
↓
真实 StudentService
调用:
service.addStudent(...)
自动输出:
[INFO] addStudent 开始
[INFO] addStudent 执行耗时 2 ms
于是动态代理也真正进入项目。
8.8 V9:进入 JavaWeb
到了 JavaWeb 阶段,可以把:
StudentApplication
替换成:
Controller
把:
students.dat
替换成:
MySQL
形成:
浏览器
↓
Controller
↓
Service
↓
Mapper
↓
MySQL
你会发现:
业务核心并没有完全推倒重来,只是输入输出方式和数据存储方式升级了。
这就是软件分层的价值。
九、项目验收
这个项目不要用:
我看懂代码了
作为完成标准。
真正的验收标准如下。
9.1 第一阶段:基础能力
关闭教程和 AI 自动补全后,能够独立写出:
Student 类
List<Student>
添加学生
查询学生
修改学生
删除学生
菜单循环
9.2 第二阶段:集合能力
能够独立增加:
姓名搜索
成绩排序
班级统计
平均分
最高分
最低分
并能解释:
为什么使用 List
为什么可以使用 Map
什么时候需要 Set
9.3 第三阶段:异常能力
能够处理:
重复学号
不存在学生
非法年龄
非法成绩
非法数字输入
文件读写异常
并能够解释:
哪些属于业务异常
哪些属于数据访问异常
9.4 第四阶段:IO 能力
能够独立实现:
程序启动读取学生
添加后保存
修改后保存
删除后保存
重新启动数据仍存在
9.5 第五阶段:设计能力
不给代码,只给需求:
编写一个控制台图书管理系统。
你应该能够自己推导:
Book
BookApplication
BookService
BookRepository
然后实现:
添加
删除
修改
查询
搜索
排序
统计
持久化
如果可以做到这一步:
说明你掌握的不只是“学生管理系统代码”,而是掌握了一套可以迁移的程序设计方法。
9.6 最终闭卷挑战
请完全关闭本教程、ChatGPT 和代码补全。
只保留下面这句话:
使用 JDK 21 独立实现一个控制台学生管理系统,要求包含实体类、CRUD、查询、排序、去重、统计、异常处理与文件持久化。
然后从空项目开始。
第一步不要写代码。
先在纸上写:
1. 需求
2. 数据模型
3. 类设计
4. 数据结构
5. 调用流程
6. 异常场景
7. 文件格式
8. 测试用例
然后再编码。
当你能从:
需求
自己走到:
设计
↓
编码
↓
Debug
↓
测试
你就真正完成了 JavaSE 从“知识学习”到“程序开发”的第一次跨越。