PenguinCoCo 後端專案重構之旅 - 資料庫怎麼設計?

今天將會開啟一連串後端專案重構的系列文章。這個專案叫做PenguinCoCo,這個專案是我與我朋友進行前後端分工的方式進行開發的。這個專案前身其實是當初我大學的指導教授希望在她的程式課程中,能夠改程式作業不用那麼人工化。簡單來說,這個專案性質類似於 Online Judge。學生們可以上該平台線上繳交代碼,並且及時批改給出成績等功能。只是後來我們加上了課程系統進去,把這個專案越來越複雜化了。

老實說這個專案,是我第一次接觸 Web 後端框架,而我挑戰的大魔王竟然是Java的 Web 後端框架,Springboot,為什麼這麼說呢,這個框架對於新手而言是相對難以入手的,相較於 Python 的平易近人的 Flask、Django 或 Node.js 的 express 等後端框架,差距其實有點大的。有機會,我會重頭發 Java 的 Web 框架的教學,從傳統的 Servlet Jsp 到現在的 Springboot,這之間其實經歷了相當大的巨變,也相當的不好學。

這次的系列文章,並不是給新手看的,而是給有相關 Spring 框架開發經驗的人看的,或者是懂其他程式語言框架的人。這系列文章,我將會從原本寫好的專案,從頭開始分析、重構。因為說真的,我個人覺得裡面的代碼已經雜亂不堪,是時候該重構了!至於為什麼要把它寫得越來越雜亂不堪,兩個原因:

  • 初次學習 Web 框架
  • 需求不斷變更

因為我是第一次學,所以我不免會走過不少的坑,也導致為了開發速度只好寫的不甚理想。再來就是需求會不斷變更,在一開始開發,自然不會想到太多後面的需求,也導致開發到後面會變成彈性不佳,導致需求一增加,程式碼就像滾雪球一樣,越滾越大,當然,也越滾越亂。

OK!開始吧!

面對需求,資料庫究竟要怎麼設計?

以我的作法,是先了解目前系統會有什麼功能,而這些功能主要由哪些角色去使用。目前的系統主要需求如下:

  • 老師可以創建課程,並且加入助教及學生名單,而在課程中可以建立題目,讓學生去做題。
  • 助教可以幫忙老師建立題目。
  • 學生進入系統後可以選擇屬於它的課程,進行做題等動作。

這是目前 **PenguinCoCo** 的主要需求。從這些需求就可以先得知一個重要的訊息:角色身分,也就是

  • 老師
  • 助教
  • 學生

所以在資料庫的表格上,就可以建立這三種角色的 table。當然在重構前,其實我有兩個作法:

  • 第一種作法就是建立一個 person table,這個會員裡面存放的欄位分別有 id、account、password、name、member_id,再分別建立 teacher table、assistant table、student table。在 member_id 當作外來鍵參考老師、助教、學生的 id。這麼做的好處在於,因為老師、助教、學生的共同欄位有 account、password、name,這樣統一放在 person table,節省空間,再來還有是我可以透過 person table 再另外去存放一個欄位是 role_id,也就是代表該 person,是哪種身分。之所以需要 role,是因為之後會需要權限控制去控制 API 的存取。但是經過我的嘗試之後發現的缺點就是:
    • Spring 的 JPA 並不支援這種方式,也許可以,但是我一直沒嘗試成功。老實講 JPA 這東西有時候滿複雜的,有時間是該花很多時間去讀它的 document,不過經過我多次嘗試及爬文,還是沒實作出來這種方法。
  • 第二種做法就是按照以往的想法,直接建立老師、助教、學生三個表格,各自存放其所需的欄位,但是這樣會有一個缺點就是角色身分這個欄位就只能在這三個表格每個都要去用外來鍵去參考 Role 這個表格的身分權限。但這樣其實很多餘,因為這三個實體,實體名稱就是代表他的身分,又要特地定義一個欄位去 reference Role 這個表格的身分。所以後來我想了想這樣的方式並不彈性。而且這樣的方式還不如直接開一個欄位,叫做 authority,直接定義身分,比如叫做老師、助教、學生。

綜合以上兩點,我最後採用較彈性的方式如下:

一樣定義三種實體,老師、助教、學生,各自存放它所需要的欄位,但不特別多加一個欄位,存放身分。而是將身分放置在程式裡面去定義,雖然這樣的方式,會有點 Hardcode,但我覺得還好,因為我們系統不太會有多重身分的情況,再者,這個方式往後要改也能快速改成另外一種方式,反而如果弄成存進資料庫的形式,往後要改必定會耗費一些功夫。

再來,除了角色實體的定義,還需要定義一些實體,裡面會存放相關系統功能的資料。

  • 題目
  • 批改
  • 抄襲
  • 意見回饋
  • 題庫

題目的資訊總需要存在資料庫吧?不然題目去哪裡存取呢。批改則是當學生進行作答題目的動作,系統進行自動批改後,會有該學生的程式碼、分數、錯誤訊息等等資訊,這也需要存起來,學生才能看到自己的成績回饋。抄襲的話,也就是老師或助教可以知道該題目目前作答的人,學生的程式碼那些人抄襲程度高,這也需要存放在資料庫,不然每次要看抄襲,都要重新進行抄襲評判的動作,非常耗時。意見回饋,則是我們給學生可以在課程中回饋給老師的意見,讓老師在後台可以看到,這也需要存放在資料庫。題庫是用來存放歷年來的題目,用來新增題目的時候可以直接匯入。

我們從系統功能就可以看到這些需要存放在資料庫的資料,我們就可以一一去建立這些實體,並且了解他們之間的關係為何。

展示程式碼

當以上資料庫的實體關係都分析好之後,最重要的是要如何運用 Spring 的 ORM 框架,這邊我們採用的 JPA 去實踐這些實體關係。

在這邊我建議先決定好 package 關係:

code-1

通常我會命名為 model,model 這個 package 裡面放置就是對應到我們資料庫實體的一些 class。當然還可以用實體名稱當作 package name 分更細,這個原因是因為有些實體它的 class 其中幾個欄位可能是由其他 class 所組合而成的。如果全部都混再一起,之後維護肯定很麻煩。

再來展示三個身分實體的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@EqualsAndHashCode(callSuper = true)
@Entity
@Data
@NoArgsConstructor
public class Teacher extends AbstractUser {

@OneToMany(mappedBy = "teacher", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Course> courses;

public Teacher(String account, String password, String name, List<Course> courses) {
super(account, password, name);
this.courses = courses;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@EqualsAndHashCode(callSuper = true)
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Assistant extends AbstractUser {

@ManyToMany
@JoinTable(name = "assistant_course", joinColumns = @JoinColumn(name = "assistant_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private List<Course> courses;

public Assistant(String account, String password, String name, List<Course> courses) {
super(account, password, name);
this.courses = courses;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@EqualsAndHashCode(callSuper = true)
@Entity
@Data
@NoArgsConstructor
public class Student extends AbstractUser {

private String studentClass;
@ManyToMany
@JoinTable(name = "student_course", joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private List<Course> courses;
@OneToMany(mappedBy = "bestStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Problem> bestProblems;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Judge> judges;
@OneToMany(mappedBy = "referencedStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Copy> referencedCopies;
@OneToMany(mappedBy = "referenceStudent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Copy> referenceCopies;
@OneToMany(mappedBy = "student", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Feedback> feedbacks;

public Student(String account, String password,
String name, String studentClass,
List<Course> courses, List<Judge> judges,
List<Problem> bestProblems, List<Copy> referencedCopies,
List<Copy> referenceCopies, List<Feedback> feedbacks) {
super(account, password, name);
this.studentClass = studentClass;
this.courses = courses;
this.bestProblems = bestProblems;
this.judges = judges;
this.referencedCopies = referencedCopies;
this.referenceCopies = referenceCopies;
this.feedbacks = feedbacks;
}

}

這邊特別推崇 Java 的套件,lombok,可以用來大量降低 Java 重複的程式碼。另外 JPA 這個 ORM 框架,其實我覺得對於新手來說真的滿難懂的,難懂的地方在於關聯關係的設置,例如一對一、一對多、多對多,再來及聯更新、刪除等的需求,都是需要花一些時間去了解的。這個都可能可以開好幾篇文章去講解了。可是如果了解後,它提供的方便性又是很強大的,值得我們去學習。值得我們去學習。

其他會用到的功能實體,就不展示了,因為真的很多,這系列的文章也不是從頭教你如何建立類似 OnlineJudge 的系統,主要就是分享及思考流程。

總結

這篇文章主要就是帶給大家思考資料庫怎麼設計的思路。當然這其實很多可以說了,我也不保證我這樣設計就是最理想。但是我覺得最重要的就是彈性的問題,因為需求的關係,資料庫的欄位可能會隨時更改,甚至新增實體,若是在第一次沒辦法寫得很有彈性,在後續的維護會很麻煩。

對了,我採用的資料庫是用 PostgreSQL,而不是常見的 MySQL,但其實 PostgreSQL 已經在業界是非常流行囉。因為它是免費 + 開源,加上性能也是非常棒的。之所以採用 RDBMS,而不採用最近很潮的 NoSQL,我是覺得簡單的判別方法就是如果你有大量的關聯關係,請不要找死使用 NoSQL,保證搞死你… 因為這個系統非常原始是採用 MongoDB,從那之後我就體認到大量的關聯關係用 NoSQL 一堆坑,再來其實會造成數據不正確等問題。有機會在寫文分享了。

最後最後!請聽我一言!

如果你還沒有註冊 Like Coin,你可以在文章最下方看到 Like 的按鈕,點下去後即可申請帳號,透過申請帳號後可以幫我的文章按下 Like,而 Like 最多可以點五次,而你不用付出任何一塊錢,就能給我寫這篇文章的最大的回饋!