控制台学生管理系统 | JavaSE

控制台学生管理系统

一、项目目标

前面学习 JavaSE 时,我们一直在解决一个个相对独立的问题:

  • Student 类怎么定义?
  • ArrayList 怎么保存对象?
  • Stream 怎么过滤、排序和统计?
  • 异常怎么处理?
  • File 与 IO 怎么读写文件?
  • 方法和类应该怎么拆分?

而真正的软件开发不是:

今天写一个 ArrayList,明天写一个 IO。

而是:

为了完成一个业务需求,把多个技术组织成一个完整系统。

因此,这个学生管理系统的核心目标并不是“再练一次增删改查”,而是完成第一次真正意义上的 JavaSE 综合工程训练

完成本项目后,应能够:

  1. 根据需求分析系统应该有哪些功能。
  2. 根据业务对象设计实体类。
  3. 使用集合保存和管理对象。
  4. 实现学生信息的 CRUD。
  5. 实现条件查询、排序、去重和统计。
  6. 使用异常处理业务错误。
  7. 使用 IO 将学生数据持久化到磁盘。
  8. 将程序拆分为不同职责的类。
  9. 理解最基础的分层设计思想。
  10. 独立完成一个规模达到数百行代码的 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 数据文件损坏测试

可以:

  1. 先关闭程序。
  2. 手动修改 students.dat
  3. 再次启动。

由于这是:

二进制文件

随意修改很可能导致:

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 从“知识学习”到“程序开发”的第一次跨越。