問題領域介紹#

本模組介紹一個線上學生管理系統 (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,以及 EnrollmentDisenrollment 兩個 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);
    }
}

enrollments collection 被設為 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 的三大好處——ScalabilityPerformanceSimplicity——來分析:

效能問題#

記憶體內篩選#

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 方法過於龐大#

StudentControllerupdate 方法是所有問題的集中體現:

  • 方法過長且巢狀層級過深,這是明顯的 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。唯一的封裝小問題是 removeEnrollmentaddDisenrollmentComment 應該合併為一個方法,因為它們總是一起使用。

小結#

本模組介紹了貫穿整個課程的線上學生管理系統範例專案,並揭示了其程式碼的兩大核心缺陷:

  • 讀寫共用同一模型 (Single model for both reads and writes):導致無法最佳化資料庫查詢。ORM 不支援所需的查詢類型,被迫在記憶體中篩選資料;同時容易遇到 N+1 query problem
  • CRUD-based thinking:將所有學生的修改操作塞進一個巨大的 update 方法,違反 SRP,導致程式碼複雜度不必要地增長。StudentDto 作為萬用 DTO,職責不清

這兩個問題在長期維護的專案中會不斷累積複雜度,最終使應用程式淪為沒人敢修改的 legacy application。Domain-Driven DesignCQRS 正是解決這些問題的有效方法。下一個模組將開始從 CRUD-based interface 重構為 task-based interface