實際開發裡一定會遇到這種情境:今天又多了一個新的 API 來源,而且帶來了新的欄位,這時候 db 該怎麼辦?要直接改 schema 嗎?還是讓 Hibernate 自動幫你建?
@Table
的 uniqueConstraints
/ indexes
對上實際 DDL@PrePersist
/@PreUpdate
)open-in-view: false
, ddl-auto: none
, 批次寫入)資料庫的 schema(column、index、constraint…)並不是一成不變。例如:一開始只有 users(id, name),後來想加上 email 欄位,之後又要加 orders 表等等。
每一次 schema 的改變(新增/修改/刪除欄位或表),就稱為一次 migration。
不過當資料庫很大的時候你總不可能:大家都記住今天要去 ALTER TABLE 加欄位,更不能靠手動打一遍。
Flyway 是一個資料庫 schema 版本控制的 open source 工具。
版本化:每個 migration 腳本用檔名代表版本,例如:
自動執行:Spring 啟動時,Flyway 會去資料庫裡查一張內部表(flyway_schema_history),上面紀錄了已經跑過的 migration。如果 DB 只停在 V1,它就會依序執行 V2、V3,如果已經到 V3,就不會重複執行。
https://documentation.red-gate.com/fd/getting-started-with-flyway-184127223.html
In pom.xml
:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
flyway-core
含版本管理、SQL 腳本執行、schema_history 表的邏輯。SERIAL
、BIGINT
、ON CONFLICT
等 PostgreSQL 特有的語法。如果換 MySQL,就要加 flyway-mysql
。In application.yml
:
flyway:
enabled: true
locations: classpath:db/migration
config:
import: "optional:configtree:/run/secrets/"
flyway.locations: classpath:db/migration
: Flyway 預設就掃 classpath:db/migration
不過你也可以更改,告訴 Flyway 去 哪裡找 migration 檔。
classpath:db/migration
= src/main/resources/db/migration/
目錄下。V1__xxx.sql
V2.1__xxx.sql
這樣 major/minor version 的數字規則,就會被當成 migration script。resources
└── db
└── migration
└── V1__init.sql
Spring Boot 會自動把 src/main/resources
打包進 classpath
。
Then what is classpath actually? Specifically, Java app compile 完後會被 load 到 JVM 執行,那麼 java app 用到的 external resources 如何被 access?,也就是 JVM 要怎麼找得到呢?
Day 7 也用圖書館來比喻什麼是 Environment
那麼這裡也借用圖書館:
Check Day 7|貓咪的持久化 🐾 (中):Spring Data JPA、 DataSource、HikariCP
前幾篇有提到 @Entity
告訴 JPA 這個 Java class 是要 map 成 DB 裡的 table 的。@Table
則是詳細設定說,這個 Entity 要對應到哪一張表,以及這張表上的一些規則。
Hibernate 啟動時,會掃描所有 Entities,並讀取 @Entity 與 @Table 上的 metadata。Day5 我們說過,annotation 就像是「小標籤」,框架會透過 Reflection API 在 runtime 去解析這些標籤,進而操控對應的 class 與 method。
check Day 5|Echo 貓咪😸: Line Webhook & 自訂 Annotation
這些 metadata 會被 Hibernate 轉換成對應的 DDL 指令(CREATE TABLE
, ALTER TABLE
, CREATE INDEX
等等)。
遇到的問題是:如果設定 spring.jpa.hibernate.ddl-auto=update
,Hibernate 就會依照這些 annotation 自動幫建/改表。然而,這種自動修改 schema 的方式不利於版本控制。
所以我們利用 Flyway,並取消 ddl-auto ,這樣 Hibernate 不會自己動 schema,達到版控。而且 Hibernate 仍然會依據 @Table
來做 mapping(知道你的欄位對應 DB 哪個 column、該怎麼生成 SQL)。
@Table 是設計表結構的藍圖,也是 Hibernate 執行 ORM mapping 時的規則表。
https://thorben-janssen.com/entity-lifecycle-model/
前文有提到 Entity Manager 會 track Entity 那麼 Entity 被 track 的狀態循環我們稱 Lifecycle。 Entity 在 Hibernate 有四種狀態
1 Transient
Cat cat = new Cat();
cat.setName("vanillasky");
persistence context 還不知道 c 這個物件的產生。
2 Managed
em.persist(cat);
Day7 有提到 EntityManager
是在 persistence context 管理並追蹤 entityem.persist()
或透過 repository 的 save()
之後,這個物件會被 Persistence Context 追蹤。最後若 commit()
transaction,資料會真的寫進 DB。
3 Detached
Session.evict(entity)
or Session.clear()
),它就「脫管」。merge()
才能更新。Removed
em.remove()
之後,Entity 標記為刪除。DELETE
。em.remove(cat);
Persistence context is an implementation of the Unit of Work patter
JPA EntityManager and Hibernate’s Session are an implementation of the persistence context concept.
Hibernate 的設計模式是 Unit of Work,而 Session 正是它的具體實作方式。
Hibernate 不是單純的 SQL mapper,而是一個 ORM 工具。要達成這件事,需要 persistence context 來記住目前有哪些物件被管理、改動了什麼、該不該 flush 到 DB。
在 Spring Data JPA 裡,我們大部分只會接觸 JPA 層級 API(EntityManager),Spring 幫我們包好了:
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityTransaction;
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
Cat cat = new Cat();
cat.setName("Mimi");
em.persist(cat);
tx.commit();
em.close();
但如果想深入理解 Hibernate 的底層運作,我們可以直接用 Hibernate API:
import org.hibernate.Session;
import org.hibernate.Transaction;
Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();
Cat cat = new Cat();
cat.setName("Mimi");
session.persist(cat);
tx.commit();
session.close();
Hibernate Session Lifecycle (with Cat 🐱)
1 開啟 Session
一開始 Persistence Context 是空的。開啟 Session 就像打開一個資料夾,準備記錄所有操作。
Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();
2 "查"資料(data in DB → Java object)
List<Cat> cats = session.createQuery("from Cat", Cat.class).getResultList();
3 修改物件
Cat mimi = cats.stream()
.filter(c -> c.getId() == 2L)
.findFirst()
.get();
mimi.setName("vanillaSky"); // 改名了,但還沒進 DB
4 Transaction commit / flush
當 commit.()
,Hibernate 會自動生成 UPDATE
SQL,把改動同步到 DB。
tx.commit();
5 關閉 Session
當這個 Session 結束時,整個 Persistence Context 也會被清空。
換句話說,貓咪(Entity)雖然還存在 JVM 的記憶體裡,但它已經變成 Detached — Hibernate 不再追蹤它的狀態,也不會再自動同步到 DB。
session.close();
mimi.setName("vanilla-chocolate");
// JVM 裡還能改名字但 Hibernate 不會再同步到 DB
In fact, Hibernate’s Session doesn’t care where a re-attached entity comes from.
把一個 Detached entity 用 merge() 或 update() 交還給 Session,它就會重新開始追蹤,不管這個物件是
結果都是一樣的:它會被 re-attach 成 Managed 狀態。
除了4 Entity status,JPA/Hibernate 也提供了 Lifecycle Events(也就是 callbacks),讓我們能在特定階段 hook 邏輯。熟悉 Spring AOP 應該很好理解,就是在特定時間點,攔截下來先做其他事(Spring AOP 跟 Lifecycle Events 邏輯很像僅此而已~)。
e.g. @PrePersist
:在 DB INSERT
前幫忙 entity 補值。
@PrePersist
void prePersist() {
if (firstSeenAt == null) firstSeenAt = Instant.now();
}
JPA 提到:@PrePersist
: before persist is called for a new entity@PostPersist
: after persist is called for a new entity@PreRemove
: before an entity is removed@PostRemove
: after an entity has been deleted@PreUpdate
: before the update operation@PostUpdate
: after an entity is updated@PostLoad
: after an entity has been loaded
https://www.baeldung.com/jpa-entity-lifecycle-events
我們根據前面的 Entity 加上他的 @Table
@Entity
@Table(
name = "articles",
uniqueConstraints = {
@UniqueConstraint(name = "uk_articles_source_external",
columnNames = {"source", "external_id"}),
@UniqueConstraint(name = "uk_articles_url_hash_hex",
columnNames = {"url_hash_hex"})
},
indexes = @Index(name = "idx_articles_published_at",
columnList = "published_at")
)
public class ArticleEntity {
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
private UUID id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 40)
private SourceType source;
@Column(name = "external_id", length = 128)
private String externalId;
@Column(nullable = false, columnDefinition = "text")
private String title;
@Column(nullable = false, columnDefinition = "text")
private String url;
@Column(name = "url_hash_hex", length = 64, nullable = false)
private String urlHashHex;
private String author;
@Column(name = "published_at", columnDefinition = "timestamptz")
private Instant publishedAt;
@Column(name = "first_seen_at", nullable = false, columnDefinition = "timestamptz")
private Instant firstSeenAt;
@Column(name = "updated_at", columnDefinition = "timestamptz")
private Instant updatedAt;
@PrePersist
void prePersist() {
Instant now = Instant.now();
if (firstSeenAt == null) firstSeenAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
void preUpdate() {
updatedAt = Instant.now();
}
// getters/setters...
}
@Service
public class ArticleService {
private final ArticleJpaRepository repo;
public ArticleService(ArticleJpaRepository repo) {
this.repo = repo;
}
@Transactional
public void saveAllFeedItem(List<FeedItem> feedItems) {
repo.saveAll(ArticleMapper.toNewEntityList(feedItems));
}
@Transactional(readOnly = true)
public List<ArticleEntity> getAllArticles() {
return repo.findAll();
}
@Transactional(readOnly = true)
public List<ArticleEntity> latestOfEach(List<String> sourceTypes, int n) {
return repo.findLatestOfEach(sourceTypes, n);
}
}