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.
![]()
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.

| Class | Trách nhiệm |
|---|---|
Student | mộ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 con | những kiểu thất bại mà chương trình chủ động báo ra |
StudentRepository | tập sinh viên đang có trong bộ nhớ, và tra cứu theo id |
StudentStore / TextFileStore | biến tập đó thành file và ngược lại |
StudentManagerApp | menu, 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;
}

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;
}
}
update và remove 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.10 vì toString() định dạng bằng %.2f. Lưu trữ giữ giá
trị; trình bày thêm số 0.
Menu loop
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.");
}

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ừ InvalidFieldException
mà Student.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:
- 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.
- 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
findByIdthì không. - 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. - Một kiểu lưu trữ khác. Viết
CsvStorecài đặtStudentStorevớ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. - Lớp học. Thêm class
Coursegiữ danh sách sinh viên và sĩ số tối đa, rồi choStudentmộ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ỗi | Hậu quả |
|---|---|
| Kiểm tra dữ liệu trong class console | quy 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ến | object 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ừ findAll | người gọi đi vòng qua add, và map âm thầm lệch dữ liệu |
Bắt Exception trong menu | mộ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 Scanner | mỗi cái đọc đệm trước, và input biến mất |
| Không khai báo charset cho file | file dữ liệu hỏng ngay khi có một cái tên mang dấu |
| Lưu chuỗi đã định dạng | 3.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. ArrayList và HashMap 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.