問題領域介紹#
本模組介紹一個線上學生管理系統 (Online Student Management System) 作為整個課程的範例專案。這個應用程式具備以下功能:
- 建立學生:輸入姓名與 Email,可選擇註冊最多兩門課程
- 更新學生:修改個人資訊、變更選課、退選課程
- 刪除學生:從系統中移除學生
- 搜尋與篩選:依據所選課程名稱或課程數量篩選學生列表
業務規則#
系統包含幾項重要的業務規則:
- 每位學生最多只能註冊兩門課程(但 Product Owner 表示未來可能增加上限)
- 註冊課程時必須提供成績 (Grade),成績為必填欄位
- 退選課程時必須提供退選原因 (Disenrollment Comment),否則 API 會回傳錯誤
- 可以將某門課程轉換 (Transfer) 為另一門課程
應用程式碼介紹#
專案由兩個部分組成:API 與 Logic(後端)以及 UI(靜態 Web 專案)。後端使用 Spring Boot JPA 搭配 Hibernate 作為 ORM,Hibernate 會根據 annotation 自動建立資料表。
Domain Model#
Course#
Course 是最簡單的 entity,僅包含名稱與學分數:
@Entity
public class Course {
@Id
@GeneratedValue
private long id;
private String name;
private int credits;
// getters and setters
}Enrollment#
Enrollment 代表學生與課程之間的關聯,包含成績:
@Entity
public class Enrollment {
@Id
@GeneratedValue
private long id;
private Student student;
private Course course;
private Grade grade;
public Enrollment(Student student, Course course, Grade grade) {
this.student = student;
this.course = course;
this.grade = grade;
}
public void update(Course course, Grade grade) {
this.course = course;
this.grade = grade;
}
}Grade 是一個 enum,可能的值為 A、B、C、D、E、F,每個值對應一個整數。
Disenrollment#
Disenrollment 記錄學生退選的歷史,包含學生、課程、日期時間與退選原因:
@Entity
public class Disenrollment {
@Id
@GeneratedValue
private long id;
private Student student;
private Course course;
private Date dateTime;
private String comment;
public Disenrollment(Student student, Course course, String comment) {
this.student = student;
this.course = course;
this.comment = comment;
this.dateTime = new Date();
}
}Student#
Student 是主要的 domain class,包含姓名、Email,以及 Enrollment 和 Disenrollment 兩個 collection:
@Entity
public class Student {
@Id
@GeneratedValue
private long id;
private String name;
private String email;
private List<Enrollment> enrollments = new ArrayList<>();
private List<Disenrollment> disenrollments = new ArrayList<>();
public Student(String name, String email) {
this.name = name;
this.email = email;
}
public void enroll(Course course, Grade grade) throws Exception {
if (enrollments.size() >= 2)
throw new Exception("Cannot have more than 2 enrollments");
Enrollment enrollment = new Enrollment(this, course, grade);
enrollments.add(enrollment);
}
public void removeEnrollment(Enrollment enrollment) {
enrollments.remove(enrollment);
}
public void addDisenrollmentComment(Enrollment enrollment, String comment) {
Disenrollment disenrollment = new Disenrollment(
enrollment.getStudent(), enrollment.getCourse(), comment);
disenrollments.add(disenrollment);
}
}
enrollmentscollection 被設為 private,外部程式碼無法直接修改,必須透過enroll()、removeEnrollment()等方法操作。這是良好的封裝 (Encapsulation) 實踐。使用 collection 而非兩個固定屬性,是因為未來可能增加註冊上限,避免過早 denormalize 資料庫結構。
API 層:StudentController#
StudentController 是 REST API 的核心,接受 UnitOfWork(Hibernate session 與 transaction 的封裝)並建立兩個 Repository:
@RestController
@RequestMapping("api/students")
public class StudentController {
private UnitOfWork unitOfWork;
private StudentRepository studentRepository;
private CourseRepository courseRepository;
@GetMapping()
public List<StudentDto> GetList(String enrolled, int number) {
return convertToDtos(studentRepository.getList(enrolled, number));
}
@PostMapping()
public void create(@RequestBody StudentDto dto) { /* ... */ }
@DeleteMapping("{id}")
public void delete(long id) { /* ... */ }
@PutMapping("{id}")
public void update(long id, @RequestBody StudentDto dto) { /* ... */ }
}- getList:回傳學生列表,支援兩個篩選參數——所選課程名稱與課程總數
- create:建立學生,讀取 DTO 中的姓名和 Email,並根據課程資訊進行註冊
- delete:根據 ID 找到學生並刪除
- update:最複雜的方法,處理姓名/Email 更新、註冊、退選、轉課等多種操作
StudentDto#
StudentDto 是一個 data transfer object,同時用於讀取和寫入:
public class StudentDto {
private long id;
private String name;
private String email;
private String course1;
private String course1Grade;
private String course1DisenrollmentComment;
private int course1Credits;
private String course2;
private String course2Grade;
private String course2DisenrollmentComment;
private int course2Credits;
}這個 DTO 是典型的 jack-of-all-trades——同時用於顯示資料給用戶端和接收更新資料。某些欄位在特定場景下是多餘的,例如更新時不需要填寫 credits 欄位,因為它們只在 UI 顯示時使用。
Repository 層#
StudentRepository 負責實際的資料庫查詢與篩選:
public class StudentRepository {
public List<Student> getList(String enrolledIn, int numberOfCourses) {
Queryable<Student> query = unitOfWork.query<Student>();
if (!enrolledIn.isEmpty()) {
query = query.Where(x => x.enrollments.any(
e => e.getCourse().getName() == enrolledIn));
}
List<Student> result = query.toList();
if (numberOfCourses != null) {
result = result.Where(x => x.getEnrollments().size()
== numberOfCourses).toList();
}
return result;
}
}應用程式碼的缺陷#
雖然這類 CRUD 應用程式非常常見,但它存在幾個嚴重的問題。從 CQRS 的三大好處——Scalability、Performance 和 Simplicity——來分析:
效能問題#
記憶體內篩選#
getList 方法的第一段篩選(依課程名稱)使用 Queryable,查詢在資料庫端執行,這是好的。但呼叫 query.toList() 之後,後續的篩選(依課程數量)就變成在記憶體中執行:
List<Student> result = query.toList(); // 強制載入所有資料到記憶體
if (numberOfCourses != null) {
// 這段篩選在記憶體中進行,而非資料庫
result = result.Where(x => x.getEnrollments().size()
== numberOfCourses).toList();
}這意味著資料庫會回傳過多的資料,然後在記憶體中手動篩選。在只有少量學生的範例中感受不到差異,但在數十萬甚至數百萬筆記錄的真實系統中,效能會嚴重下降。
ORM(如 Hibernate、Entity Framework)不支援依據 one-to-many 關聯的元素數量進行篩選,因此被迫在記憶體中完成這段邏輯。這是 read/write model 共用同一模型的直接後果。
N+1 查詢問題#
另一個常見的效能問題是 N+1 query problem:ORM 先查詢所有學生,再對每位學生分別查詢其 Enrollment,再對每個 Enrollment 查詢對應的 Course。原本一次資料庫來回就能完成的工作,變成了多次來回,學生越多,round trip 越多。
雖然可以透過指示 ORM 使用 eager loading 來緩解,但這並非總是可行,而且非常容易遺漏。
程式碼複雜度問題#
Update 方法過於龐大#
StudentController 的 update 方法是所有問題的集中體現:
- 方法過長且巢狀層級過深,這是明顯的 code smell
- 它同時處理了太多不同的業務邏輯:退選、新增註冊、轉課、更新個人資訊
- 第一門課和第二門課的邏輯幾乎完全重複
- 明顯違反了 Single Responsibility Principle (SRP)
StudentDto 職責不明確#
StudentDto 同時承擔讀取和寫入的角色,導致某些欄位在某些場景下完全無用。這是典型的 CRUD-based thinking 產物。
關於 Domain Model 的澄清#
值得注意的是,雖然這個應用程式存在 CRUD-based thinking 的問題,但它的 domain model 並非 Anemic Domain Model。Student entity 具有良好的封裝:collection 被隱藏、無參數建構子為 protected、提供 rich constructor 需要 name 和 email、
enroll()方法會檢查 invariant。唯一的封裝小問題是removeEnrollment和addDisenrollmentComment應該合併為一個方法,因為它們總是一起使用。
小結#
本模組介紹了貫穿整個課程的線上學生管理系統範例專案,並揭示了其程式碼的兩大核心缺陷:
- 讀寫共用同一模型 (Single model for both reads and writes):導致無法最佳化資料庫查詢。ORM 不支援所需的查詢類型,被迫在記憶體中篩選資料;同時容易遇到 N+1 query problem
- CRUD-based thinking:將所有學生的修改操作塞進一個巨大的 update 方法,違反 SRP,導致程式碼複雜度不必要地增長。
StudentDto作為萬用 DTO,職責不清
這兩個問題在長期維護的專案中會不斷累積複雜度,最終使應用程式淪為沒人敢修改的 legacy application。Domain-Driven Design 和 CQRS 正是解決這些問題的有效方法。下一個模組將開始從 CRUD-based interface 重構為 task-based interface。