iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Software Development

Spring 冒險日記:六邊形架構、DDD 與微服務整合系列 第 8

Day 8|貓咪的持久化 🐾 (下):Flyway 版控、@Table、Entity Lifecycle & Event

  • 分享至 

  • xImage
  •  

前言

實際開發裡一定會遇到這種情境:今天又多了一個新的 API 來源,而且帶來了新的欄位,這時候 db 該怎麼辦?要直接改 schema 嗎?還是讓 Hibernate 自動幫你建?

  • 用 Flyway 管理 schema: 別再讓 Hibernate 自動亂改,並順便學習 classpath (JVM loading)
  • @TableuniqueConstraints / indexes 對上實際 DDL
  • 熟悉 Entity 生命週期(@PrePersist/@PreUpdate
  • 查詢處理:Method Name → JPQL → Native
  • JPA 設定(open-in-view: false, ddl-auto: none, 批次寫入)

Flyway and migration

What is migration

資料庫的 schema(column、index、constraint…)並不是一成不變。例如:一開始只有 users(id, name),後來想加上 email 欄位,之後又要加 orders 表等等。

每一次 schema 的改變(新增/修改/刪除欄位或表),就稱為一次 migration。

不過當資料庫很大的時候你總不可能:大家都記住今天要去 ALTER TABLE 加欄位,更不能靠手動打一遍。

What is flyway

Flyway 是一個資料庫 schema 版本控制的 open source 工具。

  • 版本化:每個 migration 腳本用檔名代表版本,例如:

    • V1__init.sql → 建立 users 表
    • V2__add_email_to_users.sql → 加 email 欄位
    • V3__create_orders.sql → 建立 orders 表
  • 自動執行:Spring 啟動時,Flyway 會去資料庫裡查一張內部表(flyway_schema_history),上面紀錄了已經跑過的 migration。如果 DB 只停在 V1,它就會依序執行 V2、V3,如果已經到 V3,就不會重複執行。

https://ithelp.ithome.com.tw/upload/images/20250923/20178775SHYiafBWyb.png
https://documentation.red-gate.com/fd/getting-started-with-flyway-184127223.html

Use Flyway in Spring Boot

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 engine 就在 flyway-core 含版本管理、SQL 腳本執行、schema_history 表的邏輯。
  • Flyway 支援不同 DB 的 dialect,因為我們用 PostgreSQL,就需要這個 extension 來認得 SERIALBIGINTON 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

classpath

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

  • classpath 就像是是「圖書館的目錄系統」,告訴 JVM (閱讀者) 要去哪個書架找 class、resource (書) 。如果沒給 JVM 目錄,JVM 只會在當前目錄找。
  • 我們給 JVM 那麼多個找書的路徑,若有重複的書,JVM 會照「搜尋順序」決定先讀誰。這個搜尋規則,是採用雙親委派模型 (Parent Delegation Model) 與 ClassLoader 的機制。
    https://www.baeldung.com/java-classloaders

@Table

前幾篇有提到 @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 時的規則表。

Entity Lifecycle and Lifecycle Events

What is Entity Lifecycle?

https://ithelp.ithome.com.tw/upload/images/20250923/201787753Tkm0UXc8M.png
https://thorben-janssen.com/entity-lifecycle-model/

前文有提到 Entity Manager 會 track Entity 那麼 Entity 被 track 的狀態循環我們稱 Lifecycle。 Entity 在 Hibernate 有四種狀態

  • Transient
  • Managed
  • Detached
  • Removed

1 Transient

  • 剛 new 出來的物件,Hibernate 完全不知道它的存在。
  • 它不在 Persistence Context 裡,也沒存到 DB。
Cat cat = new Cat();  
cat.setName("vanillasky");  

persistence context 還不知道 c 這個物件的產生。

2 Managed

em.persist(cat);  

Day7 有提到 EntityManager 是在 persistence context 管理並追蹤 entity
em.persist() 或透過 repository 的 save() 之後,這個物件會被 Persistence Context 追蹤。最後若 commit() transaction,資料會真的寫進 DB。

3 Detached

  • 原本是 Managed,但 Persistence Context 關閉(Session.evict(entity) or Session.clear()),它就「脫管」。
  • Detached 物件仍然存在於 JVM 裡,但 Hibernate 不再追蹤它。
  • 如果改它的屬性,不會同步到 DB。要重新 merge() 才能更新。

Removed

  • em.remove() 之後,Entity 標記為刪除。
  • 標記為刪除不是真的 DB 執行刪除 要 Transaction commit 時,才會真的執行 DELETE
    (The entity stays in the persistent context store until the end of the unit of work.)
em.remove(cat);

Session in Hibernate

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)

  • Hibernate 會把 DB rows 映射成 Java objects,並放進 Persistence Context
List<Cat> cats = session.createQuery("from Cat", Cat.class).getResultList();

3 修改物件

  • 你只要改 Java 物件,Hibernate 就會自動標記成 dirty。
    dirty 這個字是不是很眼熟?在「計算機組織」的 memory cache 裡也有 dirty bit,用來延遲更新、提升效率。
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,它就會重新開始追蹤,不管這個物件是

  • 從之前的 Session load 出來的
  • 或是新 new 出來、再把 ID 設好

結果都是一樣的:它會被 re-attach 成 Managed 狀態。

What is Lifecycle Events

除了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

延續 Day7 的 Repository 跟 Service

我們根據前面的 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);
    }
}

上一篇
Day 7|貓咪的持久化 🐾 (中):Spring Data JPA、 DataSource、HikariCP
系列文
Spring 冒險日記:六邊形架構、DDD 與微服務整合8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言