多年教訓:根據DDD設計原則改變JPA/Hibernate的使用方式 - lorenzo

banq發表於2021-06-17

我最近一直在更新一些培訓材料,思考JPA更好的教學方法和討論方式。我一直在思考的一件事是我們通常是如何使用JPA?這裡結合我所經歷的(和觀察到的)痛苦,應該如何改變傳統使用方式?
JPA通常被視為一組註釋(或XML檔案),它們提供O/R(物件關係)對映資訊。大多數開發人員認為他們知道和使用的對映註釋越多,他們得到的好處就越多。但是在過去的幾年裡,與中小規模的巨石/單體/整體系統(大約有200張表/實體)的搏鬥教會了我一些別的東西。
教訓:
  • 按ID引用實體(僅對映聚合中的實體關係)
  • 不要讓JPA竊取你的ID(儘可能避免@GeneratedValue)
  • 使用特殊join來join不相關的實體

  

按識別符號ID引用實體
僅對映DDD聚合中的實體關係。
傳統 JPA或Hibernate教程(和培訓)通常會涵蓋所有可能的實體關係對映。在教學基本對映之後,許多對映將從簡單的單向@manytone對映開始。然後繼續雙向@OneToMany和@ManyToOne。不幸的是,大多數情況下,他們沒有明確指出,這種對映關係不是很好。因此,初學者在完成訓練時往往會認為,不對映相關實體是錯誤的。他們錯誤地認為外來鍵欄位必須對映為相關實體。

@Entity
public class SomeEntity {
    // ...
    @ManyToOne private Country country;
    // ...
}
 
@Entity
public class Country {
    @Id private String id; // e.g. US, JP, CN, CA, GB, PH
    // ...
}

將上面@ManyToOne 應該改為@Column,將相關實體的主鍵對映為一個欄位即可:

@Entity
public class SomeEntity {
    // ...
    @Column private String countryId;
    // ...
}
 
@Entity
public class Country {
    @Id private String id; // e.g. US, JP, CN, CA, GB, PH
    // ...
}


對映所有實體關係會增加了不必要的遍歷的機會,這通常會導致不必要的記憶體消耗。這也會導致不必要的EntityManager操作級聯。
如果您只處理少數幾個實體/表,這可能並不多。但是當與幾十個(如果不是幾百個)實體一起工作時,它就變成了維護的噩夢。
何時對映相關實體?
僅當相關實體位於聚合中時才對映它們(在DDD中)。

聚合是領域驅動設計中的一種模式。DDD聚合是可以作為單個單元處理的域物件的叢集。例如訂單及其行專案,它們將是單獨的物件,但將訂單(及其行專案)視為單個聚合非常有用。
https://martinfowler.com/bliki/DDD_Aggregate.html


@Entity
public class Order {
    // ...
    @OneToMany(mappedBy = "order", ...) private List<OrderItem> items;
    // ...
}
 
@Entity
public class OrderItem {
    // ...
    @ManyToOne(optional = false) private Order order;
    // ...
}

更現代的聚合設計方法提倡在聚合之間進行更乾淨的分離。透過儲存聚合根的ID(唯一識別符號)而不是完整的引用來引用聚合根是一種很好的做法。
如果我們展開上面的簡單訂單示例,那麼行專案(OrderItem類)不應該有到產品的@ManyToOne對映,相反,它應該只有產品的ID:

@Entity
public class Order {
    // ...
    @OneToMany(mappedBy = "order", ...) private List<OrderItem> items;
    // ...
}
 
@Entity
public class OrderItem {
    // ...
    @ManyToOne(optional = false) private Order order;
    // @ManyToOne private Product product; // <-- Avoid this!
    @Column private ... productId;
    // ...
}


但是…如果產品(聚合根實體)的@Id欄位對映為@GeneratedValue呢?我們是否必須先持久化/flush重新整理,然後使用生成的ID值?
那麼,join呢?我們還能在JPA中Join這些實體嗎?
 

別讓JPA偷走你的標識
使用@GeneratedValue最初可能會使對映簡單易用。但是,當您開始透過ID(而不是透過對映關係)引用其他實體時,這將成為一個挑戰。
如果產品(聚合根實體)的@Id欄位對映為@GeneratedValue,則呼叫getId()可能返回null。當它返回null時,行專案(OrderItem類)將無法引用它!
在所有實體都有一個非空Id欄位的環境中,按Id引用任何實體都會變得更容易。此外,始終具有非空的Id欄位,使得equals(Object)和hashCode()更容易實現。
因為所有Id欄位都顯式初始化,所以所有(聚合根)實體都有一個接受Id欄位值的公共建構函式。可以新增一個受保護的no-args建構函式來讓JPA滿意。

@Entity
public class Order {
    @Id private Long id;
    // ...
    public Order(Long id) {
        // ...
        this.id = id;
    }
    public Long getId() { return id; }
    // ...
    protected Order() { /* as required by ORM/JPA */ }
}

在寫這篇文章的時候,我發現了James Brundege的一篇文章(2006年釋出的), Don't Let Hibernate Steal Your Identity (感謝Wayback Machine),他說,不要讓Hibernate管理你的Id。但願我早點聽他的勸告。

但要小心!當使用Spring Data JPA save()儲存一個在其@Id欄位上不使用@GeneratedValue的實體時,在預期的INSERT之前會發出一個不必要的SQL SELECT。這是由於SimpleJpaRepository的save()方法(如下所示)。它依賴於@Id欄位(非空值)的存在來確定是呼叫persist(Object)還是merge(Object)。

public class SimpleJpaRepository // ...
    @Override
    public <S extends T> save(S entity) {
        // ...
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }
}

精明的讀者會注意到,如果@Id欄位從不為null,save()方法將始終呼叫merge()。這會導致不必要的SQL SELECT(在預期的INSERT之前)。
幸運的是,解決方法很簡單-實現 Persistable<ID>.

@MappedSuperclass
public abstract class BaseEntity<ID> implements Persistable<ID> {
    @Transient
    private boolean persisted = false;
    @Override
    public boolean isNew() {
        return !persisted;
    }
    @PostPersist
    @PostLoad
    protected void setPersisted() {
        this.persisted = true;
    }
}
以上還意味著對實體的所有更新都必須首先將現有實體載入到永續性上下文中,然後將更改應用到託管實體。

 

使用特殊Join連線來join不相關的實體
那麼,連線join呢?既然我們透過ID引用了其他實體,那麼如何在JPA中連線join不相關的實體呢?
在jpa2.2版本中,不相關的實體不能連線。但是,我無法確認這是否已經成為3.0版的標準,在3.0版中,所有javax.persistence引用都被重新命名為jakarta.persistence。

給定OrderItem實體,缺少@manytone對映會導致它無法與產品實體聯接。

@Entity
public class Order {
    // ...
}
 
@Entity
public class OrderItem {
    // ...
    @ManyToOne(optional = false) private Order order;
    @Column private ... productId;
    // ...
}

值得慶幸的是,Hibernate 5.1.0+(2016年釋出)和EclipseLink 2.4.0+(2012年釋出)一直在支援無關實體的連線。這些連線也稱為特殊連線 ad-hoc joins。

SELECT o
  FROM Order o
  JOIN o.items oi
  JOIN Product p ON (p.id = oi.productId) -- supported in Hibernate and EclipseLink

另外,這也是一個API問題(支援兩個根實體的JOIN/ON)。我真的希望它能很快成為一種標準。
 

相關文章