Command Palette

Search for a command to run...

[Java Basics] Dự án tổng hợp: app quản lý sinh viên trên console

Ba mươi lăm bài trước, mỗi bài dạy một ý. Bài này ghép chúng lại thành một chương trình chạy được: ứng dụng console quản lý danh sách sinh viên, kiểm tra mọi thứ người dùng gõ vào, báo lỗi mà không chết, và lưu dữ liệu ra file text để lần chạy sau tiếp tục được từ chỗ lần trước dừng.

App quản lý sinh viên trên console: menu, danh sách và file text phía sau

Không có gì mới ở đây. Class, constructor, tính đóng gói, interface, exception, ArrayList, HashMap, Scanner và file I/O đều đã học rồi. Cái mới là chuyện ghép: class nào giữ quyết định nào, một phép kiểm tra nên nằm ở đâu, và chuyện gì xảy ra tại các chỗ nối. Đó là phần mà một khóa học gồm các ví dụ rời rạc không bao giờ cho thấy, và cũng chính là phần phân biệt code chỉ chạy đúng đường đẹp với code sống sót trước người dùng thật.

Chúng ta sẽ xây cái gì

Đây là chương trình hoàn chỉnh đang chạy. Phiên terminal thật, ghi lại từ chính chương trình — giá trị sau mỗi prompt là thứ đã được gõ vào:

=== Quản lý sinh viên ===
Chưa có dữ liệu trong students.txt

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S001
Tên: Nguyễn Văn An
Tuổi: 20
GPA: 3.75
Đã thêm: S001   Nguyễn Văn An            20  3.75

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S002
Tên: Trần Thị Bình
Tuổi: 22
GPA: 3.10
Đã thêm: S002   Trần Thị Bình            22  3.10

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S003
Tên: Lê Minh Châu
Tuổi: 19
GPA: 3.92
Đã thêm: S003   Lê Minh Châu             19  3.92

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S001
Tên: Nguyễn V. An
Tuổi: 21
GPA: 3.5
Lỗi: id sinh viên S001 đã tồn tại

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S004
Tên: Phạm Quốc Dũng
Tuổi: hai mươi
GPA: 3.0
Lỗi: age: "hai mươi" không phải số nguyên

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 2
ID     TÊN                    TUỔI  GPA
S001   Nguyễn Văn An            20  3.75
S002   Trần Thị Bình            22  3.10
S003   Lê Minh Châu             19  3.92
3 sinh viên

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 3
Id: S002
Tìm thấy: S002   Trần Thị Bình            22  3.10

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 4
Id: S003
Hiện tại: S003   Lê Minh Châu             19  3.92
Tên [Lê Minh Châu]: 
Tuổi [19]: 21
GPA [3.92]: 3.95
Đã sửa: S003   Lê Minh Châu             21  3.95

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 5
Id: S001
Đã xóa S001

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 2
ID     TÊN                    TUỔI  GPA
S002   Trần Thị Bình            22  3.10
S003   Lê Minh Châu             21  3.95
2 sinh viên

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 6
Đã lưu 2 sinh viên vào students.txt

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 0
Tạm biệt.

Năm lệnh, hai input sai bị từ chối, một id trùng bị chặn, và cuối cùng là một file dữ liệu trên đĩa. Phần còn lại của bài giải thích nó được ghép ra sao.

Thiết kế

Bốn trách nhiệm, bốn loại class. Quy tắc quyết định code nằm ở đâu rất đơn giản: mỗi class chỉ nên có một lý do để phải sửa.

Các class trong dự án và class nào phụ thuộc class nào

ClassTrách nhiệm
Studentmột sinh viên, và các quy tắc định nghĩa thế nào là sinh viên hợp lệ
StudentException và các lớp connhững kiểu thất bại mà chương trình chủ động báo ra
StudentRepositorytập sinh viên đang có trong bộ nhớ, và tra cứu theo id
StudentStore / TextFileStorebiến tập đó thành file và ngược lại
StudentManagerAppmenu, prompt, và không gì khác

Mũi tên chỉ đi một chiều. Student không biết gì về repository; repository không biết gì về file; ngoài StudentManagerApp ra không class nào biết là có console. Chính điều đó cho phép bạn thay file text bằng database sau này, hoặc điều khiển cùng repository đó từ giao diện web, mà không phải đụng vào các quy tắc domain.

Student: object không thể sai

Quyết định hữu ích nhất trong cả chương trình là Student tự kiểm tra trong constructor và bất biến sau đó. Mọi field đều final, không có setter, và constructor ném exception nếu có giá trị nào không hợp lệ. Hệ quả đáng nói thẳng ra: nếu bạn đang cầm một Student, thì giá trị bên trong nó hợp lệ. Không đoạn code nào khác phải kiểm tra lại lần nữa.

// Student.java
import java.util.Locale;

/**
 * One student. Immutable: every field is validated once in the constructor,
 * so an object that exists is an object whose values are legal.
 */
public class Student {

    /** The one character a text field may not contain, so a student fits on one line. */
    public static final String SEPARATOR = "|";

    /** The same character as a regular expression: "|" alone means "or" to split(). */
    private static final String SEPARATOR_REGEX = "\\|";

    private static final int MIN_AGE = 16;
    private static final int MAX_AGE = 80;
    private static final double MIN_GPA = 0.0;
    private static final double MAX_GPA = 4.0;

    private final String id;
    private final String name;
    private final int age;
    private final double gpa;

    public Student(String id, String name, int age, double gpa) throws InvalidFieldException {
        this.id = requireText("id", id);
        this.name = requireText("name", name);
        this.age = requireAge(age);
        this.gpa = requireGpa(gpa);
    }

Các hàm kiểm tra là private static vì chúng chạy trước khi object tồn tại — chúng chạy trong lúc constructor còn đang tính ra giá trị để gán:

    private static String requireText(String field, String value) throws InvalidFieldException {
        if (value == null || value.trim().isEmpty()) {
            throw new InvalidFieldException(field, "không được để trống");
        }
        if (value.contains(SEPARATOR)) {
            throw new InvalidFieldException(field, "không được chứa " + SEPARATOR);
        }
        return value.trim();
    }

    private static int requireAge(int value) throws InvalidFieldException {
        if (value < MIN_AGE || value > MAX_AGE) {
            throw new InvalidFieldException("age",
                    value + " nằm ngoài khoảng " + MIN_AGE + ".." + MAX_AGE);
        }
        return value;
    }

    private static double requireGpa(double value) throws InvalidFieldException {
        if (value < MIN_GPA || value > MAX_GPA) {
            throw new InvalidFieldException("gpa",
                    value + " nằm ngoài khoảng " + MIN_GPA + ".." + MAX_GPA);
        }
        return value;
    }

Hai method static nữa lo hai chỗ mà dữ liệu thô đi vào — bàn phím và file. Việc phân tích chuỗi đặt ở đây thay vì ở class console, để cả hai lối vào dùng chung một bộ quy tắc và chung một thông báo lỗi:

    /** Builds a student from four raw strings, so the numbers are parsed in one place. */
    public static Student parse(String id, String name, String age, String gpa)
            throws InvalidFieldException {
        return new Student(id, name, parseAge(age), parseGpa(gpa));
    }

    /** Reads back one line written by toLine(). */
    public static Student fromLine(String line) throws InvalidFieldException {
        String[] parts = line.split(SEPARATOR_REGEX, -1);
        if (parts.length != 4) {
            throw new InvalidFieldException("format",
                    "cần 4 field ngăn cách bởi " + SEPARATOR + ", chỉ có " + parts.length);
        }
        return parse(parts[0], parts[1], parts[2], parts[3]);
    }

    /** One line of the data file. Concatenation, so the decimal point is never a comma. */

Để ý split(SEPARATOR_REGEX, -1). Tham số thứ hai giữ lại các field rỗng ở cuối, nên một dòng kết thúc bằng GPA rỗng vẫn là dòng bốn field với field thứ tư sai, chứ không phải dòng ba field. Và ký tự ngăn cách phải được escape vì "|" đứng một mình mang nghĩa hoặc trong biểu thức chính quy — đúng loại chi tiết âm thầm làm sai nếu bỏ qua.

Một cây exception nhỏ

Bài 32 lập luận rằng một custom exception xứng đáng tồn tại khi người gọi sẽ xử lý nó khác đi. Ở đây, một kiểu cha chung là thứ cho phép tầng console viết đúng một catch bao trọn mọi thất bại có chủ đích, trong khi từng kiểu con vẫn nói chính xác chuyện gì đã sai:

// StudentException.java
/** Base type for every error this application raises on purpose. */
public abstract class StudentException extends Exception {

    protected StudentException(String message) {
        super(message);
    }

    protected StudentException(String message, Throwable cause) {
        super(message, cause);
    }
}
// InvalidFieldException.java
/** A field value the rules of the domain refuse to accept. */
public class InvalidFieldException extends StudentException {

    public InvalidFieldException(String field, String problem) {
        super(field + ": " + problem);
    }
}

// DuplicateIdException.java
/** An id that is already taken by another student. */
public class DuplicateIdException extends StudentException {

    public DuplicateIdException(String id) {
        super("id sinh viên " + id + " đã tồn tại");
    }
}

// StudentNotFoundException.java
/** An id that no student in the repository carries. */
public class StudentNotFoundException extends StudentException {

    public StudentNotFoundException(String id) {
        super("không có sinh viên nào mang id " + id);
    }
}

// StorageException.java
/** The data file could not be read or written, or it does not parse. */
public class StorageException extends StudentException {

    public StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

StorageException là kiểu duy nhất nhận cause, vì nó là kiểu duy nhất bọc một thất bại ở tầng thấp hơn — IOException từ hệ thống file, hoặc InvalidFieldException từ một dòng hỏng. Những kiểu còn lại chính là nguyên nhân gốc.

Class cha để abstract vì lý do bài 29 đã nêu: không ai nên ném thẳng một StudentException, bởi nó chẳng nói gì hơn ngoài "có gì đó sai".

Repository: một list và một map, luôn đồng bộ

Repository giữ danh sách sinh viên. Nó cần hai thứ cùng lúc — thứ tự thêm vào, để in danh sách, và tra cứu tức thì theo id, cho mọi việc còn lại. List cho cái thứ nhất, map cho cái thứ hai, và class này tồn tại để đảm bảo hai cái đó không bao giờ lệch nhau.

public class StudentRepository {

    private final List<Student> students = new ArrayList<>();
    private final Map<String, Student> byId = new HashMap<>();

    public void add(Student student) throws DuplicateIdException {
        String id = student.getId();
        if (byId.containsKey(id)) {
            throw new DuplicateIdException(id);
        }
        students.add(student);
        byId.put(id, student);
    }

    public Student findById(String id) throws StudentNotFoundException {
        Student found = byId.get(id);
        if (found == null) {
            throw new StudentNotFoundException(id);
        }
        return found;
    }

Thêm sinh viên: phân tích, kiểm tra, chặn id trùng, rồi lưu vào cả hai cấu trúc

add kiểm tra map chứ không kiểm tra list, vì containsKey chỉ tốn một bước còn quét list thì không. findAll trả về new ArrayList<>(students) — một bản sao. Bài 37 sẽ gọi cách làm ngược lại là getter rò rỉ: đưa ra list thật thì người gọi nào cũng có thể thêm một sinh viên chưa hề đi qua add, và map sẽ không hay biết.

    public void update(Student updated) throws StudentNotFoundException {
        String id = updated.getId();
        int index = indexOf(id);
        if (index < 0) {
            throw new StudentNotFoundException(id);
        }
        students.set(index, updated);
        byId.put(id, updated);
    }

    public void remove(String id) throws StudentNotFoundException {
        int index = indexOf(id);
        if (index < 0) {
            throw new StudentNotFoundException(id);
        }
        students.remove(index);
        byId.remove(id);
    }

    /** A copy, so a caller cannot add or remove behind the repository's back. */
    public List<Student> findAll() {
        return new ArrayList<>(students);
    }

    private int indexOf(String id) {
        for (int i = 0; i < students.size(); i++) {
            if (students.get(i).getId().equals(id)) {
                return i;
            }
        }
        return -1;
    }
}

updateremove ghi vào cả hai cấu trúc. Đó là cái giá thật của việc giữ hai góc nhìn trên cùng một dữ liệu, và cũng chính là lý do chúng nằm sau một class thay vì là hai biến trong main.

Lưu trữ nằm sau một interface

Ứng dụng không nên quan tâm sinh viên được lưu trong file text. Và nó không quan tâm thật: nó chỉ phụ thuộc vào một interface với hai method.

// StudentStore.java
import java.util.List;

/** Where the students live between two runs of the program. */
public interface StudentStore {

    void save(List<Student> students) throws StorageException;

    List<Student> load() throws StorageException;
}

Phần cài đặt mới là chỗ file thật sự xuất hiện — try-with-resources, charset khai báo tường minh, mỗi sinh viên một dòng:

public class TextFileStore implements StudentStore {

    private final Path path;

    public TextFileStore(String fileName) {
        this.path = Path.of(fileName);
    }

    @Override
    public void save(List<Student> students) throws StorageException {
        try (BufferedWriter out = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
            for (Student student : students) {
                out.write(student.toLine());
                out.newLine();
            }
        } catch (IOException e) {
            throw new StorageException("không ghi được " + path, e);
        }
    }

Đọc lại thì dài hơn, vì đọc mới là lúc mọi thứ hỏng. Một dòng không phân tích được sẽ được dịch thành StorageException mang theo số dòng, thứ thông tin mà người dùng thật sự cần:

    @Override
    public List<Student> load() throws StorageException {
        List<Student> loaded = new ArrayList<>();
        if (!Files.exists(path)) {
            return loaded;
        }
        try (BufferedReader in = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            int number = 0;
            while ((line = in.readLine()) != null) {
                number++;
                if (line.trim().isEmpty()) {
                    continue;
                }
                try {
                    loaded.add(Student.fromLine(line));
                } catch (InvalidFieldException e) {
                    throw new StorageException(path + " dòng " + number + ": " + e.getMessage(), e);
                }
            }
        } catch (IOException e) {
            throw new StorageException("không đọc được " + path, e);
        }
        return loaded;
    }
}

StandardCharsets.UTF_8 được truyền tường minh ở cả hai phía. Bài 35 đã giải thích vì sao: mặc định trở thành UTF-8 từ Java 18, nhưng viết ra trong code mới là thứ khiến điều đó đúng trên mọi JDK và mọi máy.

Sau khi lưu, file trông như sau:

S002|Trần Thị Bình|22|3.1
S003|Lê Minh Châu|21|3.95

Một chi tiết đáng để ý: GPA gõ vào là 3.10 nhưng lưu xuống thành 3.1. toLine() nối chuỗi từ một double, và 3.10 với 3.1 là cùng một số — số 0 ở cuối chưa bao giờ là dữ liệu, nó chỉ là cách trình bày. Danh sách in ra 3.10toString() định dạng bằng %.2f. Lưu trữ giữ giá trị; trình bày thêm số 0.

Mọi thứ người dùng nhìn thấy nằm trong một class, và mỗi mục menu là một method nhỏ.

    public void run() {
        System.out.println("=== Quản lý sinh viên ===");
        loadAtStartup();
        boolean running = true;
        while (running) {
            printMenu();
            String choice = ask("Chọn: ");
            if (inputEnded) {
                System.out.println("(hết dữ liệu nhập)");
                break;
            }
            switch (choice) {
                case "1" -> addStudent();
                case "2" -> listStudents();
                case "3" -> findStudent();
                case "4" -> updateStudent();
                case "5" -> deleteStudent();
                case "6" -> saveToFile();
                case "0" -> running = false;
                default -> System.out.println("Lựa chọn không hợp lệ: " + choice);
            }
        }
        System.out.println("Tạm biệt.");
    }

Menu loop: in menu, đọc lựa chọn, điều hướng, rồi quay lại

switch dùng nhãn mũi tên, nên không cần break và không case nào rơi sang case kế tiếp. default bắt các lựa chọn gõ nhầm thay vì lờ chúng đi.

Mỗi lệnh đều theo cùng một hình dạng: thu thập input, làm việc, bắt StudentException, in thông báo. Đúng một catch trong mỗi method, ở đúng tầng biết cách nói chuyện với người dùng:

    private void addStudent() {
        try {
            String id = ask("Id: ");
            String name = ask("Tên: ");
            String age = ask("Tuổi: ");
            String gpa = ask("GPA: ");
            Student student = Student.parse(id, name, age, gpa);
            repository.add(student);
            System.out.println("Đã thêm: " + student);
        } catch (StudentException e) {
            System.out.println("Lỗi: " + e.getMessage());
        }
    }

Đó là toàn bộ chiến lược xử lý lỗi. Một giá trị sai chỉ khiến người dùng mất một dòng output và một lần gõ lại. Nó không bao giờ khiến họ mất chương trình, và cũng không bao giờ khiến họ mất những sinh viên đã nhập trước đó.

Đọc input mà không vấp Scanner

Hai chi tiết trong ask đáng giá hơn vẻ ngoài của chúng:

    private String ask(String label) {
        System.out.print(label);
        System.out.flush();          // print() không tự flush, và hasNextLine() thì chặn,
                                     // thiếu dòng này prompt sẽ hiện quá muộn
        if (!in.hasNextLine()) {
            inputEnded = true;
            System.out.println();
            return "";
        }
        return in.nextLine().trim();
    }

System.out.flush() có mặt vì print không tự flush — chỉ ký tự xuống dòng mới kích hoạt việc đó. Thiếu dòng flush, prompt nằm lại trong buffer trong khi chương trình đã chặn để chờ input, và người dùng nhìn vào một dòng trống mà không biết phải gõ gì.

hasNextLine() có mặt vì input có thể hết. Nếu người dùng bấm Ctrl-D, hoặc bạn chạy chương trình với input chuyển hướng từ file, nextLine() không còn gì để trả về. Không có phép kiểm tra này, nó ném exception và chương trình chết kèm stack trace:

Exception in thread "main" java.util.NoSuchElementException: No line found
	at java.base/java.util.Scanner.nextLine(Scanner.java:1660)
	at StudentManagerApp.ask(StudentManagerApp.java:139)
	at StudentManagerApp.addStudent(StudentManagerApp.java:70)
	at StudentManagerApp.run(StudentManagerApp.java:32)
	at StudentManagerApp.main(StudentManagerApp.java:20)

Có phép kiểm tra, cũng lần chạy đó kết thúc lịch sự:

=== Quản lý sinh viên ===
Đã nạp 2 sinh viên từ students.txt

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: Id: Tên: 
Tuổi: 
GPA: 
Lỗi: age: "" không phải số nguyên

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 
(hết dữ liệu nhập)
Tạm biệt.

Để ý cả chương trình đọc qua đúng một Scanner, tạo một lần trong main bên trong try-with-resources rồi truyền vào constructor. Tạo Scanner thứ hai trên System.in là cách kinh điển để mất input, vì mỗi cái đọc đệm trước một cách độc lập.

Chạy thử

Biên dịch tất cả bằng một lệnh rồi chạy:

javac *.java
java StudentManagerApp

Lần chạy thứ hai chứng minh file đã làm đúng việc của nó — sinh viên quay lại mà không phải gõ lại:

=== Quản lý sinh viên ===
Đã nạp 2 sinh viên từ students.txt

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 2
ID     TÊN                    TUỔI  GPA
S002   Trần Thị Bình            22  3.10
S003   Lê Minh Châu             21  3.95
2 sinh viên

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 3
Id: S003
Tìm thấy: S003   Lê Minh Châu             21  3.95

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 0
Tạm biệt.

Giá trị sai bị từ chối từng cái một, và chương trình vẫn chạy tiếp:

=== Quản lý sinh viên ===
Đã nạp 2 sinh viên từ students.txt

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S004
Tên: Lê|Minh
Tuổi: 20
GPA: 3.0
Lỗi: name: không được chứa |

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 1
Id: S004
Tên: Phạm Quốc Dũng
Tuổi: 20
GPA: 4.5
Lỗi: gpa: 4.5 nằm ngoài khoảng 0.0..4.0

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 0
Tạm biệt.

Cái đầu tiên chính là lúc quy tắc về ký tự ngăn cách phát huy tác dụng. Không có nó, một cái tên chứa | sẽ ghi ra dòng năm field mà sau này không tài nào đọc lại được — dữ liệu hỏng ngay lúc lưu, còn thất bại thì mãi tới lần chạy sau mới lộ ra.

Khi file dữ liệu bị hỏng

File bị sửa tay, bị cắt cụt vì đầy đĩa, hoặc bị merge sai. Cả hai trường hợp dưới đây đều là lần chạy thật trên file có dòng thứ ba bị hỏng:

=== Quản lý sinh viên ===
Lỗi: students.txt dòng 3: format: cần 4 field ngăn cách bởi |, chỉ có 3
Bắt đầu với danh sách rỗng.

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 0
Tạm biệt.
=== Quản lý sinh viên ===
Lỗi: students.txt dòng 3: gpa: "abc" không phải số
Bắt đầu với danh sách rỗng.

1) Thêm  2) Danh sách  3) Tìm  4) Sửa  5) Xóa  6) Lưu  0) Thoát
Chọn: 0
Tạm biệt.

Số dòng đến từ bộ đếm trong load(), phần còn lại của thông báo đến từ InvalidFieldExceptionStudent.fromLine đã ném ra, được bọc lại làm cause. Chương trình báo sai ở đâu và sai gì, rồi khởi động với danh sách rỗng thay vì thoát — đó là một quyết định, không phải sơ suất. Nếu bạn muốn thà không khởi động còn hơn liều ghi đè lên file chưa đọc được, chỉ cần thêm một System.exit(1), và với dữ liệu thật thì đó thường là lựa chọn tốt hơn.

Tự mở rộng chương trình

Chương trình được giữ nhỏ có chủ đích để bạn sửa được. Mỗi mục dưới đây là một bài tập thật, xếp theo độ khó tăng dần:

  1. Sắp xếp danh sách. Thêm một mục menu in danh sách theo GPA giảm dần. List đã có sẵn; bạn cần một phép so sánh và một bản sao để không phá thứ tự đang lưu.
  2. Tìm theo tên. Thêm chức năng tìm gần đúng theo tên, không phân biệt hoa thường. Hãy nghĩ xem vì sao chức năng này buộc phải quét list trong khi findById thì không.
  3. Lưu khi thoát. Hỏi người dùng có muốn lưu không khi họ chọn 0, và theo dõi xem thật sự có gì thay đổi kể từ lần lưu trước hay chưa.
  4. Một kiểu lưu trữ khác. Viết CsvStore cài đặt StudentStore với quy tắc trích dẫn đàng hoàng, để một cái tên chứa ký tự ngăn cách vẫn hợp lệ. Không gì ngoài class store được phép thay đổi — đó chính là phép thử xem interface đã được vạch đúng chỗ hay chưa.
  5. Lớp học. Thêm class Course giữ danh sách sinh viên và sĩ số tối đa, rồi cho Student một danh sách các course đã đăng ký. Đây là chỗ bạn sẽ khám phá ra vì sao hai object trỏ vào nhau lại khó hơn vẻ ngoài của nó.

Những lỗi thường gặp khi ghép một chương trình như thế này

LỗiHậu quả
Kiểm tra dữ liệu trong class consolequy tắc chỉ đúng với input gõ từ menu, không đúng với dữ liệu nạp từ file
Student có setter, không bất biếnobject có thể trở nên sai sau khi tạo, nên mọi nơi đọc đều phải kiểm tra lại
Trả về list nội bộ từ findAllngười gọi đi vòng qua add, và map âm thầm lệch dữ liệu
Bắt Exception trong menumột lỗi trong code của chính bạn bị báo như thể người dùng gây ra
Mỗi method một Scannermỗi cái đọc đệm trước, và input biến mất
Không khai báo charset cho filefile dữ liệu hỏng ngay khi có một cái tên mang dấu
Lưu chuỗi đã định dạng3.10 ghi ra dạng text là cách trình bày; 3.1 mới là giá trị

Câu hỏi thường gặp

Vì sao Student bất biến trong khi chương trình có chức năng Sửa?

Chức năng sửa dựng một Student mới từ các giá trị đã chỉnh rồi thay thế cái cũ trong repository. Nhờ vậy giá trị mới đi qua đúng bộ kiểm tra trong constructor như một sinh viên hoàn toàn mới, và một lần sửa thất bại không đụng gì tới bản gốc. Sửa tại chỗ sẽ buộc phải kiểm tra trong từng setter mà vẫn để lộ một khoảng thời gian object đang dở dang.

Vì sao cần interface cho store khi chỉ có một cài đặt?

Vì nó đánh dấu ranh giới. StudentManagerApp được viết dựa trên StudentStore, nên không dòng nào trong đó lỡ phụ thuộc vào file được. Bài tập số 4 ở trên là phần thưởng: một cài đặt thứ hai lẽ ra không đòi hỏi thay đổi ở bất kỳ đâu khác. Nếu nó đòi, thì interface đã không phải là ranh giới thật.

Repository chỉ dùng Map có được không?

Được, và tra cứu vẫn y hệt. Bạn sẽ mất thứ tự thêm vào mà phần in danh sách đang dựa vào — HashMap không đảm bảo thứ tự nào cả, như bài 34 đã cho thấy. Giữ thêm một list là một cách để có cả hai; LinkedHashMap là cách khác, và là thứ đáng tra cứu một lần khi chương trình này đã trở nên quen tay.

Vì sao chương trình vẫn chạy tiếp sau khi gặp dòng hỏng trong file?

Vì một công cụ chạy bằng menu mà không chịu khởi động thì vô dụng với đúng người đang cần sửa file đó. Thông báo có nêu số dòng, nên người dùng mở editor lên là sửa được. Hãy chọn khác đi khi dữ liệu quan trọng hơn sự tiện lợi — một công cụ tính lương thì nên dừng hẳn.

Chương trình này chứng minh điều gì

Mọi ý tưởng trong khóa học đều xuất hiện ở đây, làm việc thật chứ không phải để minh họa cho chính nó. Class và constructor cho Student một hình dạng. Tính đóng gói và final khiến nó đáng tin. Interface tách quyết định lưu trữ khỏi cơ chế lưu trữ. Một cây exception nhỏ biến thất bại thành thông tin. ArrayListHashMap giữ dữ liệu ở đúng hai hình dạng chương trình cần. Scanner và file I/O nối nó với bàn phím và ổ đĩa.

Thứ gắn kết tất cả lại là một thói quen duy nhất: quyết định xem mỗi mẩu hiểu biết sống ở đâu, và không cho chỗ nào khác lặp lại nó. Quy tắc về sinh viên hợp lệ sống trong Student. Quy tắc id là duy nhất sống trong repository. Hiểu biết rằng dữ liệu là file text ngăn cách bằng | sống trong TextFileStore. Khi một quy tắc đổi, đúng một file phải sửa.

Bài cuối cùng khép lại khóa học bằng cách nói thẳng thói quen đó ra: cách đặt tên, độ dài method, guard clause, những cái bẫy mà javac sẽ không cảnh báo bạn, và những thực hành đáng mang theo vào mọi chương trình bạn viết từ đây trở đi.

Bài viết liên quan

[Java Basics] Bài tập tổng hợp về câu lệnh điều kiện và vòng lặp

Mười sáu bài tập Java về if, else if và vòng lặp: FizzBuzz, năm nhuận, giai thừa tràn số, đảo chữ số, kiểm tra số nguyên tố, vẽ hình sao, canh cột bằng printf, menu và validate input, mỗi bài đều có output thật, gợi ý và lỗi thường gặp.

[Java Basics] Toán tử trong Java: số học, so sánh, logic, gán và toán tử ba ngôi

Hướng dẫn đầy đủ về toán tử trong Java — số học, so sánh, logic, gán, bitwise và ba ngôi — với mọi kết quả đều được compile và chạy thật trên JDK 21, kèm các bẫy về chia số nguyên, số dư, số thực và short-circuit.

[Java Basics] Đệ quy trong Java: cách hoạt động và khi nào nên dùng

Đệ quy trong Java hoạt động ra sao: base case và recursive case, trace factorial theo từng frame, StackOverflowError thật khi thiếu base case, độ sâu đệ quy và -Xss, vì sao Fibonacci naive cần 2692537 lời gọi cho fib(30) trong khi memoisation chỉ cần 59, đệ quy so với vòng lặp, và vì sao JVM không tối ưu tail call.

[Java Basics] Interface trong Java và so sánh với abstract class

Interface trong Java trên OpenJDK 21: implements khác extends thế nào, member ngầm định public static final, một class implement nhiều interface, default, static và private method, diamond problem và Interface.super, luật class-wins, functional interface, cùng bảng so sánh chi tiết với abstract class.