(18) 使用 Liquibase 建立所需的 Table 與 ChangeSet 的介紹

Albert Hg
learning-from-jhipster
18 min readJan 14, 2021

上一篇文章我們已經將 Liquibase 導入至我們的 Spring Boot 專案中了。而這一篇文章,將會介紹如何使用 ChangeSet 來操作資料庫。所以這個部份,我們將會搭配一些資料庫設計的相關內容,來完成這次主題的展示。

這個系列文的目標主要是做一個管理系統的專案,但其實最終目的是在於學習使用這些工具,因此在系統的架構的部分我們會盡可能的簡化它,以避免模糊學習焦點。

所以要建立甚麼專案?

那麼重點來了,這個管理系統我們打算做甚麼樣的內容呢?考量到後續介紹資料庫的 Table 之間的關聯性,需要想一個「一對一、一對多、多對多」都有使用上的應用。

這裡我們假設以「健身房會員管理系統」為目標,但誠如上方所述,這個系列文重點會放在「工具的使用」,而非「專案的系統完整度」,因此後續所建立的表的內容,就不會花上太多額外的心力在每張表的內容。

如果要簡單的建立健身房會員管理系統,那麼我們可能會遇到下面幾種需求:

  • 健身房:紀錄健身房的館場名稱
  • 會員卡:會員卡決定可以進入那些健身房,或者依照卡片等級決定甚麼時候可以進入健身房
  • 會員:會員卡的持有人,記錄會員的相關資料
  • 入場紀錄:紀錄會員的入場紀錄

依照上面的幾項需求,我們就可以簡單建立表的內容,如下:

可以總結一下目前每張表之間的關係:

  • Member to Card:1–1
  • Card to Gym:*-*
  • Member to Logging:1-*
  • Gym to Logging:1-*

其中的 Card_Gym 是一張多對多關係中的「中介表」。因此包含這張中介表,在確定了要建立的表的內容後,就可以開始透過 Liquibase 來建立、管理資料庫的表了。

建立表的順序

基於我們要建立的系統,建立表以及FK Constraint(外鍵限制)的順序會如下:

  1. 建立 Member 表
  2. 建立 Card 表
  3. 加入 Member 與 Card 的 FK
  4. 建立 Gym 表
  5. 建立Card_Gym 表
  6. 加入 Member 與 Card 的 FK
  7. 加入 Card 、 Gym 與 Card_Gym 的 FK
  8. 加入 Member、 Gym 與 Logging 的 FK

假設你對於資料庫設計已經有一點接觸了,那麼你應該大概知道 FK 的用途是用於確保資料的完整性。但你可能會聽到一些說法是「盡量不要使用 FK」,不過其實這已經是另外一個議題了。這裡還是推薦你看幾篇討論串 (可以在本文的最底端找到推薦文章),相信可以更清楚的知道使用使用 FK 的優點與缺點。

而如同一開始所說的,因為這個系列文著重在工具的使用,所以我們這裡還是會對相關的表加上 FK。

加入 ChangeLog 於 master.xml 中

在上一篇文章中,我們有建立了一個檔案,master.xml,作為 Liquibase 讀取 ChangeLog 的進入位置。所以當想要加入其他的 ChangeLog 時,就必須在 master.xml 中進行設定。

先來說說建立 ChangeLog File 的小規則,在我們新增 ChangeLog File 時,會統一固定的格式作為建立的 File 名稱,格式可以依照專案管理者來決定怎麼統一,或者像這樣 {yyyyMMddHHmmssSSS}_{type}_{entityName}.xml 的格式,例如 202101081611000_addEntity_Member.xml ,就可以非常明確的知道在甚麼時間點,對哪一個 Entity 做了甚麼事情。

所以假設現在想要加入一個關於 Member 表的 ChangeLog,我們就可以依照下列的步驟進行操作:

  1. 新增一個 202101081611000_addEntity_Member.xml 的檔案,並假設這個檔案路徑位於 src/main/resources/liquibase/changelog 底下
  2. 202101081611000_addEntity_Member.xml 的路徑透過 <include> 標籤加入 master.xml
https://gist.github.com/albert-hg/c30536c2a6b16eec6e1d2a5349fa30ae

這裡用到了 <include>,他主要是用於引用其他的 ChangeLog 時所用的標籤。裡的的參數 file 用於指定路徑,而 relativeToChangelogFile 則用於設定所指定的路徑是否為相對路徑或是 classpath 的路徑。

透過 ChangeSet 建立 Table

我們已經建立好了初始化 Member 表的 ChangeLog,現在要在其中加入 ChangeSet 來 Create Teble。

在開始使用 ChangeSet 前,一樣有幾點需要注意的小規則:

  1. 一定要加上 id 的屬性值,這個 id 會被記錄在 DATABASECHANGELOG 裡,用於紀錄哪些 ChangeSet 已經被執行過,所以 id 必須得是唯一值。
  2. 一定要加上 author 的屬性值,因為 author 會於 DATABASECHANGELOG 裡記錄是誰修改了這次的內容。
  3. 所有的「新增表、修改表、刪除表」等操作,都會在 ChangeSet 中

在「新增表」的部分,我們就會需要使用 <createTable> 的元素,並且在這個元素內加上 <column> 或者加上 <constraints> 來設定這些表的 Schema 資訊。(參考)

因此,假設我們要建立 Member 表的 Schema,就會如下的程式碼片段所示:

https://gist.github.com/albert-hg/fb879795d92b5cc0c8b1ce70f68073a5

這裡特別需要注意的地方是 <column> 內的 autoIncrement 屬性值,如果想要在資料庫中操作 INSERT 時讓 ID 自動編號,透過 autoIncrement="true" 進行設定就可以讓資料庫自行管理編號的新增。

但是這裡會建議讓 autoIncrement 設定為 false,這牽扯到 Sequence 自動產生的不同方式,所以接著我們來稍微提一下不同的 Sequence 生成策略。

不同的 Sequence 生成策略

對於 Sequence 的產生,有幾種不同的方式:

1. 讓資料庫自行管理每個需要 Sequence 的欄位

如果你想要讓資料庫自動幫你管理這些 Sequence,那麼在 Liquibase 中,就可以如同上述透過 autoIncrement="true" 來完成,或者參考 Liquibase 提供的 AddAutoIncrement 來實現,像是:

https://gist.github.com/albert-hg/3eaceedc5ab39db2ad320b3340a95baa

而在對應的使用 SQL 命令來建立 Table 時,就如同下方所執行的內容:

CREATE TABLE
PUBLIC.member (
id BIGINT AUTO_INCREMENT NOT NULL,
name VARCHAR(50),
CONSTRAINT PK_MEMBER PRIMARY KEY (id)
)

接著如果你在資料庫中執行插入的命令時,就不需要指定 ID 的值,資料庫會自動使用資料庫所自行管理的 Sequence 對 ID 的部分自行加上 Sequence Value。例如在 PostgreSQL 中:

INSERT INTO member (name) VALUES ('Albert');

這裡需要別注意的是,如果你對愈多個欄位標記上 AUTO_INCREMENT ,那麼資料庫在 Sequence Table 中就會加入愈多個 Sequence 的資料,來管理每一個 Sequence 的當前狀態。

例如在 PostgreSQL 中,就可以用如下命令列,檢視當前的 Sequence:

SELECT * FROM information_schema.sequences;

而若在 <column> 中設定 autoIncrement="false" (不設定的預設值也是 false) 的話,那麼在對資料庫使用 INSERT 命令列時,ID 的部分也必需要填寫,否則就會得到「null value in column "id" violates not-null constraint」的錯誤訊息。

也因此,我們會需要其他的方式來管理 Sequence 的 Auto-Increment。

2. 透過程式的操作管理 Sequence,以進行 Auto-Increment (適用於有支援 Sequence 的資料庫)

當欄位的 autoIncrement 為 false 時,則資料庫就不會自動管理需要自動遞增的欄位了。這時我們會需要透過程式的部分來自行進行處理,有兩種方式。

2-1. 自行管理一個通用的 Sequence

我們可以在資料庫中,先建立一個統一的 Sequence,假設你在 PostgreSQL 中,就可以使用如下命令建立 一個名為 sequence_generator 的Sequence:

CREATE SEQUENCE PUBLIC.sequence_generator 
START WITH 1050 INCREMENT BY 50;

而在 Liquibase 中,如果想要建立 Sequence,可以參考如下,或參考 Liquibase 提供的 CreateSequence 說明

https://gist.github.com/albert-hg/2b28589e045d17023df45359f8247b2b

當你建立完一個通用的 Sequence 後,在程式碼的部分就可以統一將所有需要 Auto-Increment 的欄位指定透過這個 Sequence 來完成,例如剛剛所建立的 sequence_generator。而該如何指定 Sequence 的部分我們將會在之後文章中的 JPA 篇才會介紹與說明。

2–2. 自行管理多個各別的 Sequence

若你有特殊需求(?),那麼你可以透過如 2-1 的方式,建立多份 Sequence,並在需要不同 Auto-Increment 的欄位指定至對應的 Sequence 中。

3. 自行建立 Sequence Table,以進行 Auto-Increment (適用於沒有支援 Sequence 的資料庫)

這裡稍微簡單的說明一下,其實不是所有資料庫都有提供 Sequence 的功能,像是 MySQL ,所以如果要實現 Sequence 的 Auto-Increment 的話,就必須得先額外建立一張表,專門做為管理 Sequence 的表。

也因此,當我們在使用 Liquibase 來 Create Sequence 或者是使用 Auto-Increment 的時候,也必須稍微注意一下不同料庫之間的特性差異。

為什麼要分為不同的 Sequence 生成策略

其實這個問題是在我寫完上面的內容後突然意識到的問題,為什麼要分成不同的 Sequence 生成策略呢?所以我們來簡單的分析一下這兩者之間的優缺點吧!

  • 使用 Auto-increment:使用簡單、方便,由資料庫管理 Sequence,但因為每一個標記 Auto-increment 的欄位都會生成不同的 Sequence Name,因此管理上較為不易。
  • 使用 Create Sequence:可以統一使用同一個 Sequence,或者可以自定義使用多個不同的 Sequence,且 Sequence Name 也可以自行定義,所以管理方便。

因此,在甚麼樣的情境下使用哪一種 Sequence 生成策略,其實主要還是看需求。如果你需要客製化較高,或者想要自行管理 Sequence 的話,那麼使用 Create Sequence 就會是比較好的選擇。而如果沒有這麼要求 Sequence 是怎麼產生的話,那麼簡單使用 Auto-increment 來讓資料庫幫我們管理也行。

在這個系列文的專案中,我們將會採取第 2-1 的方式來進行 Sequence 的建立與使用。

透過 <addForeignKeyConstraint> 來加入 FK

加入 Member 與 Card 的 Foreign Key 的順序與規則

如同一開始說的流程:

  1. 建立 Member 表
  2. 建立 Card 表
  3. 加入 Member 與 Card 的 FK

假設我們已經將 Member 以及 Card 透過 <createTable/> 建立完成了,現在我們要將 Member 與Card 之間加入 FK 的約束。

不過在加入 FK 之前,我們要先確定這兩者之間的從屬關係,也就是必須先確定是「先有 Member 才有 Card」這件事情,因為是要先有會員,會員才會有會員卡,而若解除會員,則會員卡可以被註銷,也就是有會員資料卻沒有會員卡資料。

而在 FK 中,我們就要確定是「要在哪張表中加上 FK 」。其實簡單的思考就是:「FK 是外鍵限制,也就是限制該表的某個欄位,必須符合另外一張表的某個欄位」。所以對於 Member 與 Card 來說,則是「要在 Card 中加上 FK,以限制 Card 的 member_id 欄位符合 Member 的 id 欄位」。

加上 Foreign Key

假設我們要在 PostgreSQL 中,對 Card 表加上 FK,如果 Card 表已經存在,那我們只能透過 Alter 表後再加上 FK,像是:

ALTER TABLE Card
ADD CONSTRAINT fk_card_member_id FOREIGN KEY (member_id)
REFERENCES Member (id)

如此一來,在每次加入資料於 Card 時,就會確保 Card 的 member_id 的值一定會包含於 Member 的 id 的值。

而在 Liquibase 中,若想要替 Card 加入 FK,你可以這樣做:

https://gist.github.com/albert-hg/aa32472a9986abfd66372da459809a9e

更多關於 <addForeignKeyConstraint/> 的細節可以參考 Liquibase 提供的 addForeignKeyConstraint 說明

addForeignKeyConstraint 的 onUpdate 與 onDelete 的 CASCADE

這裡有兩個屬性值需要稍微特別提醒一下:

  1. onUpdate 屬性
  2. onDelete 屬性

這兩個屬性可以設定的值有:CASCADE、SET NULL、SET DEFAULT、RESTRICT、NO ACTION。特別需要注意的是 CASCADE,如果當這兩個屬性值被設定為 CASCADE,則當 References 的表的欄位被更新或刪除時,加入 Constraint 的欄位也會被更新或者被刪除。

不過其實不太建議直接使用 CASCADE,原因是因為當 FK 的所牽動的功能愈多,那麼可能所造成的 Side Effect 的機會就會更大,或者可能造成錯誤發生時的除錯困難。如果想要避免讓資料庫自行更動資料,想要只透過程式碼來管理資料,那麼就盡量不要設定這些屬性值會是比較好的選擇。

Composite Primary Keys

一張表可以不只有一個欄位可以做為 PK,他可以是複合式的,也就是多個欄位組合為一個 PK,這很常用於多對多的中介表 (Junction Table)。

假設已經完成建立 Card 表以及 Gym 表,接下來就要處理多對多的部分。在中介表中,我們並不會希望 (card_id, gym_id) 的資料重複,所以就會使用到 Composite Primary Keys 複合主鍵的設計。

不論是不是使用 Liquibase 進行操作,在設計表的 Schema 時,你可以在「建立表」的同時加上 Composite PK,亦或者是透過「修改表」的時候加上。

在新增表的同時加入 Composite PK

在 SQL 中,我們可以像這樣完成:

CREATE TABLE PUBLIC.card_gym (
card_id BIGINT NOT NULL,
gym_id BIGINT NOT NULL,
CONSTRAINT PK_CARD_GYM PRIMARY KEY (card_id, gym_id)
)

在 Liquibase 中,可以直接在 Constraints 加上 primaryKey="true"

https://gist.github.com/albert-hg/b5cc8f7e168c31f779002d876fa856b0

透過修改表的方式加入 Composite PK

在 SQL 中,可以像這樣完成:

ALTER TABLE PUBLIC.card_gym ADD PRIMARY KEY (card_id, gym_id)

在 Liquibase 中,可以透過 <addPrimaryKey> 來完成:

https://gist.github.com/albert-hg/7ae31ead5c418638d32bd19d49258011

接著為了確保資料的正確性與完整度,因此我們會把這張 Junction Table 的欄位與對應的表的欄位加上 Foreign Key Constrain。細節因為前面有說明過了,因此這邊就不再多贅述~。

最後完成 Logging 表

最後我們來完成這張 Logging 表,這張表主要是拿來記錄會員的進場紀錄。不曉得你有沒有疑惑過為什麼這張表的 FK 是指向 Member 而不是 Card ?

確實,在 Member 中的 id 與 Card 中的 member_id 是一對一的關係,但因為我們考慮到了以下情形:「會員被申請註銷,會員卡的資料包含可前往的 Gym 也需一併被取消,但會員資料可以不一定要被刪除」,因此在 Logging 表中所紀錄的內容,就會是 Member 的 id。

而完成 Logging 表的方式就如同之前所提過的內容一樣如法炮製,如下:

https://gist.github.com/albert-hg/7ca54d65795efd7496f418c9bd07c467

完整的程式碼你可以在文章的最後找到。

執行後的 DATABASECHANGELOG

當所有要加入的表都透過 Liquibase 加入後,並執行專案,Liquibase 就會開始操作資料庫。而 Liquibase 所加入的 DATABASECHANGELOG 表,就會如下圖所示 (以 H2 DB 為例):

(清楚的看這裡) https://cdn-images-1.medium.com/max/1400/1*SVc0wKom_M4Azr9hOD6SlA.png

如果你想要參考 liquibase 在執行期間到底都做了些甚麼事情,那麼可以參考「liquibase.log」,這裡提供了完整的 log。

文末

我們從這篇文章開始,決定了專案的方向。從需求的設定到資料庫設計,先定義出需要甚麼表以及每張表之間的關聯,最後詳述了怎麼使用 Liquibase 對資料庫進行操作,展示如何新增 <changeSet>、如何透過 <createTable> 來新增表、怎麼使用 <column> 新增欄位、怎麼搭配 <constraints> 設定欄位的約束 (例如 NOT NULL)、如何透過 <addForeignKeyConstraint> 來修改 FK 等等過程。

因此對於 Liquibase 的基本操作我們已經在這個章節詳述的算完整了,後續如果有更多進階的功能,則會在後續有需求時再提起。因為礙於篇幅的關係,所以如果你迫不及待的想知道更多關於 Liquibase 的細節,也可以上 Liquibase 的官方網站查看更多的功能細節。

Liquibase 的使用介紹大概就會在這邊告一段落,因為我們已經完整的將專案的架構擬建出來了,因次下一篇文章就會開始介紹,如何透過 ORM 的概念來與資料庫進行溝通,例如資料的取得、新增、修改、刪除等基本功能。

那我們就開始進入下一個環節吧 ─ Spring Data Jpa!

--

--

Albert Hg
learning-from-jhipster

I am a programmer but love other things. I am a nobody but keep myself going. I am a person who wishes to reach the heaven but lost the wings.