Spring-Boot-持久化最佳实践-一-

187 阅读48分钟

Spring Boot 持久化最佳实践(一)

原文:Spring Boot Persistence Best Practices

协议:CC BY-NC-SA 4.0

一、关联

第一项:如何有效塑造@OneToMany 联想

双向@OneToMany关联可能是我们的领域模型中遇到最多的关联。基于这种说法,本书在大量的例子中利用了这种联系。

要了解协会效率的超音速指南,请查看 附录 B

考虑双向懒惰@OneToMany关联中涉及的两个实体AuthorBook。在图 1-1 中,可以看到对应的@OneToMany表关系。

img/487471_1_En_1_Fig1_HTML.jpg

图 1-1

@OneToMany 表关系

因此,author表与book表有一个@OneToMany关系。一个author行可以被多个book行引用。author_id列通过引用author表主键的外键来映射这种关系。一本书不能没有作者,因此,author是父端(@OneToMany),而book是子端(@ManyToOne)。@ManyToOne关联负责将外键列与持久性上下文(一级缓存)同步。

要获得超快速但有意义的 JPA 基础指南,请参见附录 A

根据经验,使用双向@OneToMany关联而不是单向关联。你很快就会看到,第 2 条解决了单向@OneToMany的性能损失,并解释了为什么应该避免它。

编写双向@OneToMany关联的最佳方式将在下面的章节中讨论。

总是从父端级联到子端

从子端级联到父端是一种代码味道和不好的做法,这是一个明确的信号,是时候审查您的领域模型和应用设计了。想想看,一个孩子级联其父母的创造是多么不恰当或不合逻辑!一方面,一个孩子不能没有父母而存在,而另一方面,孩子级联他的父母的创造。这不符合逻辑吧?因此,根据经验,总是从父端级联到子端,如下例所示(这是使用双向关联的最重要的优点之一)。在这种情况下,我们从Author端级联到Book端,因此我们在Author实体中添加级联类型:

@OneToMany(cascade = CascadeType.ALL)

在这种情况下,不要在@ManyToOne上使用CascadeType.*,因为实体状态转换应该从父端实体传播到子端实体。

不要忘记在父端设置 mappedBy

mappedBy属性表示双向关联,必须在父端设置。换句话说,对于双向@OneToMany关联,在父端将mappedBy设置为@OneToMany,在mappedBy引用的子端添加@ManyToOne。通过mappedBy,双向@OneToMany关联发信号通知它镜像@ManyToOne子端映射。在这种情况下,我们在下面添加Author实体:

@OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author")

在父端设置 orphanRemoval

在父端设置orphanRemoval可以保证在没有引用的情况下移除子级。换句话说,orphanRemoval适合于清理那些没有所有者对象的引用就不应该存在的依赖对象。在这种情况下,我们将orphanRemoval添加到Author实体中:

@OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author",
              orphanRemoval = true)

保持关联双方的同步

通过添加到父端的辅助方法,可以很容易地使关联的两端保持同步。一般来说,add Child ()remove Child ()remove Children ()的方法会做得很好。虽然这可能代表“生存工具包”,但也可以添加更多的辅助方法。只需识别所使用的和涉及同步的操作,并将它们提取为辅助方法。如果您不努力保持关联双方的同步,那么实体状态转换可能会导致意外的行为。在这种情况下,我们将Author实体添加到以下助手中:

public void addBook(Book book) {
    this.books.add(book);
    book.setAuthor(this);
}

public void removeBook(Book book) {
    book.setAuthor(null);
    this.books.remove(book);
}

public void removeBooks() {
    Iterator<Book> iterator = this.books.iterator();

    while (iterator.hasNext()) {
        Book book = iterator.next();

        book.setAuthor(null);
        iterator.remove();
    }
}

重写 equals()和 hashCode()

通过适当地覆盖equals()hashCode()方法,应用在所有实体状态转换中获得相同的结果(这一方面在第 68 项中进行了剖析)。对于@OneToMany关联,这些方法应该在子端被覆盖。在这种情况下,我们使用自动生成的数据库标识符来覆盖这两个方法。基于自动生成的数据库标识符覆盖equals()hashCode()是一种特殊情况,详见第 68 项。要记住的最重要的一点是,对于自动生成的数据库标识符,equals()方法应该在执行相等检查之前执行标识符的null检查,而hashCode()方法应该返回一个常量值。由于Book实体在子端,我们强调这两个方面如下:

@Override
public boolean equals(Object obj) {
    ...
    return id != null && id.equals(((Book) obj).id);
}

@Override
public int hashCode() {
    return 2021;
}

在关联的两端使用延迟抓取

默认情况下,提取父端实体不会提取子实体。这意味着@OneToMany被设置为 lazy。另一方面,默认情况下,获取子实体将急切地获取其父端实体。明智的做法是将@ManyToOne显式设置为 lazy,并仅基于查询依赖于急切获取。更多详情请参见第 3 。在这种情况下,Book实体显式地将@ManyToOne映射为LAZY:

@ManyToOne(fetch = FetchType.LAZY)

注意 toString()是如何被覆盖的

如果toString()需要被覆盖,那么确保只涉及从数据库加载实体时获取的基本属性。包含惰性属性或关联将触发单独的 SQL 语句,这些语句获取相应的数据或抛出LazyInitializationException。例如,如果我们为Author实体实现了toString()方法,那么我们不会提到books集合,我们只提到基本属性(idnameagegenre):

@Override
public String toString() {
    return "Author{" + "id=" + id + ", name=" + name
        + ", genre=" + genre + ", age=" + age + '}';
}

使用@JoinColumn 指定联接列名称

由所有者实体(Book)定义的连接列存储 ID 值,并有一个到Author实体的外键。建议为此列指定所需的名称。这样,在引用它时(例如,在本地查询中),您可以避免潜在的混淆/错误。在这种情况下,我们将@JoinColumn添加到Book实体,如下所示:

@JoinColumn(name = "author_id")

作者和书籍示例

将这些先前的指令粘合在一起并用代码表达它们将会产生下面的AuthorBook样本:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author", orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        this.books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        book.setAuthor(null);
        this.books.remove(book);
    }

    public void removeBooks() {
        Iterator<Book> iterator = this.books.iterator();
        while (iterator.hasNext()) {
            Book book = iterator.next();
            book.setAuthor(null);
            iterator.remove();
        }
    }

    // getters and setters omitted for brevity

    @Override
    public String toString() {
        return "Author{" + "id=" + id + ", name=" + name
                         + ", genre=" + genre + ", age=" + age + '}';
    }
}

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String isbn;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    // getters and setters omitted for brevity

    @Override
    public boolean equals(Object obj) {

        if(obj == null) {
            return false;
        }

        if (this == obj) {
            return true;
        }

        if (getClass() != obj.getClass()) {
            return false;
        }

        return id != null && id.equals(((Book) obj).id);
    }

    @Override
    public int hashCode() {
        return 2021;
    }

    @Override
     public String toString() {
         return "Book{" + "id=" + id + ", title=" + title
                                + ", isbn=" + isbn + '}';
     }
}

GitHub 1 上有源代码。

注意删除实体操作,尤其是子实体操作。虽然CascadeType.REMOVEorphanRemoval=true会完成它们的工作,但是它们可能会产生太多的 SQL 语句。依靠批量操作通常是删除大量实体的最佳方式。要批量删除,请考虑项目 52项目 53 ,而要查看删除子实体的最佳实践,请考虑项目 6

第二条:为什么你应该避免单向的@木偶联想

考虑双向懒惰@OneToMany关联中涉及的AuthorBook实体(一个作者写了几本书,每本书只有一个作者)。试图插入一个子实体Book,将导致一个 SQL INSERT语句触发book表(将添加一个子行)。尝试删除一个子实体将导致针对book表触发一个 SQL DELETE语句(删除一个子行)。

现在,让我们假设相同的AuthorBook实体包含在单向@OneToMany关联映射中,如下所示:

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();

缺少@ManyToOne关联会导致一个单独的连接表(author_books)来管理父子关联,如图 1-2 所示。

img/487471_1_En_1_Fig2_HTML.jpg

图 1-2

@OneToMany 表关系

连接表包含两个外键,因此索引比双向@OneToMany情况下消耗更多内存。此外,拥有三个表也会影响查询操作。在双向@OneToMany关联的情况下,读取数据可能需要三个连接,而不是两个。此外,让我们看看INSERTDELETE如何在单向@OneToMany关联中行动。

让我们假设有一个名叫乔安娜·尼玛尔的作者写了三本书。数据快照如图 1-3 所示。

img/487471_1_En_1_Fig3_HTML.jpg

图 1-3

数据快照(单向@OneToMany)

常规单向@OneToMany

下面的小节处理常规单向@OneToMany关联中的INSERTREMOVE操作。

请注意,每个场景都从图 1-3 所示的数据快照开始。

坚持一个作者和他们的书

下面显示了从数据快照中持久保存作者和相关书籍的服务方法:

@Transactional
public void insertAuthorWithBooks() {

    Author jn = new Author();
    jn.setName("Joana Nimar");
    jn.setAge(34);
    jn.setGenre("History");

    Book jn01 = new Book();
    jn01.setIsbn("001-JN");
    jn01.setTitle("A History of Ancient Prague");

    Book jn02 = new Book();
    jn02.setIsbn("002-JN");
    jn02.setTitle("A People's History");

    Book jn03 = new Book();
    jn03.setIsbn("003-JN");
    jn03.setTitle("World History");

    jn.addBook(jn01);
    jn.addBook(jn02);
    jn.addBook(jn03);

    authorRepository.save(jn);
}

检查生成的 SQL INSERT语句发现,与双向@OneToMany关联相比,连接表中多了三个INSERT(对于 n 本书,有 n 个额外的INSERT):

INSERT INTO author (age, genre, name)
  VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[002-JN, A People's History]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[003-JN, World History]

-- additional inserts that are not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 2]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 3]

因此,在这种情况下,单向@OneToMany关联不如双向@OneToMany关联有效。接下来的每个场景都使用这个数据快照作为起点。

坚持现有作者的新书

由于乔安娜·尼玛尔刚刚出版了一本新书,我们必须将它添加到book表中。这一次,服务方法如下所示:

@Transactional
public void insertNewBook() {

    Author author = authorRepository.fetchByName("Joana Nimar");

    Book book = new Book();
    book.setIsbn("004-JN");
    book.setTitle("History Details");

    author.addBook(book); // use addBook() helper

    authorRepository.save(author);
}

调用此方法并关注 SQL INSERT语句会产生以下输出:

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[004-JN, History Details]

-- the following DML statements don't appear in bidirectional @OneToMany
DELETE FROM author_books
WHERE author_id = ?
Binding:[1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 2]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 3]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 4]

因此,为了插入一本新书,JPA 持久性提供者(Hibernate)从连接表中删除了所有相关的书籍。接下来,它将新书添加到内存中,并再次将结果保存回来。这远非高效,潜在的性能损失也相当明显。

删除最后一本书

删除最后一本书包括获取一个作者的相关List<Book>并从列表中删除最后一本书,如下所示:

@Transactional
public void deleteLastBook() {

    Author author = authorRepository.fetchByName("Joana Nimar");
    List<Book> books = author.getBooks();

    // use removeBook() helper
    author.removeBook(books.get(books.size() - 1));
}

调用deleteLastBook()显示以下相关 SQL 语句:

DELETE FROM author_books
WHERE author_id = ?
Binding:[1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 2]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]

因此,为了删除最后一本书,JPA persistence provider(Hibernate)从连接表中删除所有相关的书,删除内存中的最后一本书,并再次将剩余的书持久化。因此,与双向@OneToMany关联相比,有几个额外的 DML 语句表示性能损失。关联书籍越多,性能损失越大。

删除第一本书

删除第一本书包括获取一个作者的相关联的List<Book>,并从列表中删除第一本书,如下所示:

@Transactional
public void deleteFirstBook() {

    Author author = authorRepository.fetchByName("Joana Nimar");
    List<Book> books = author.getBooks();

    author.removeBook(books.get(0));
}

调用deleteFirstBook()显示以下相关 SQL 语句:

DELETE FROM author_books
WHERE author_id = ?
Binding:[1]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 2]

INSERT INTO author_books (author_id, books_id)
  VALUES (?, ?)
Binding:[1, 3]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]

因此,删除第一本书的行为与删除最后一本书的行为完全相同。

除了由动态数量的附加 SQL 语句导致的性能损失之外,我们还面临由删除和重新插入与连接表的外键列相关联的索引条目导致的性能损失(大多数数据库对外键列使用索引)。当数据库从连接表中删除与父实体关联的所有表行时,它还会删除相应的索引条目。当数据库重新插入连接表时,它也会插入索引条目。

到目前为止,结论是明确的。对于读取、写入和删除数据,单向@OneToMany关联不如双向@OneToMany关联高效。

使用@OrderColumn

通过添加@OrderColumn注释,单向@OneToMany关联变得有序。换句话说,@OrderColumn指示 Hibernate 将元素索引(每个集合元素的索引)具体化到连接表的一个单独的数据库列中,以便使用ORDER BY子句对集合进行排序。在这种情况下,每个集合元素的索引都将存储在连接表的books_order列中。在代码中:

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@OrderColumn(name = "books_order")
private List<Book> books = new ArrayList<>();

更进一步,让我们看看关联如何与@OrderColumn一起工作。

坚持作者和书籍

通过insertAuthorWithBooks()服务方法持久化快照中的作者和相关书籍会触发以下相关的 SQL 语句:

INSERT INTO author (age, genre, name)
  VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[002-JN, A People's History]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[003-JN, World History]

-- additional inserts not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_order, books_id)
  VALUES (?, ?, ?)
Binding:[1, 0, 1]

INSERT INTO author_books (author_id, books_order, books_id)
  VALUES (?, ?, ?)
Binding:[1, 1, 2]

INSERT INTO author
_books (author_id, books_order, books_id)
  VALUES (?, ?, ?)
Binding:[1, 2, 3]

看来@OrderColumn并没有带来什么好处。三个附加的INSERT语句仍然被触发。

坚持出版现有作者的新书

通过insertNewBook()服务方法持久化一本新书会触发以下相关的 SQL 语句:

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[004-JN, History Details]

-- this is not needed for bidirectional @OneToMany
INSERT INTO author_books (author_id, books_order, books_id)
  VALUES (?, ?, ?)
Binding:[1, 3, 4]

有好消息也有坏消息!

好消息是,这一次,Hibernate 没有删除相关的书籍来从内存中添加它们。

坏消息是,与双向@OneToMany关联相比,连接表中还有一个额外的INSERT语句。所以,在这种背景下,@OrderColumn带来了一些好处。

删除最后一本书

通过deleteLastBook()删除最后一本书会触发以下相关 SQL 语句:

DELETE FROM author_books
WHERE author_id = ?
  AND books_order = ?
Binding:[1, 2]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]

看来@OrderColumn在去掉最后一本书的情况下带来了一些好处。JPA persistence provider(Hibernate)并没有删除所有相关的书籍来从内存中添加剩余的书籍。

但是,与双向@OneToMany关联相比,仍然有一个额外的DELETE触发连接表。

删除第一本书

通过deleteFirstBook()删除第一本书会触发以下相关 SQL 语句:

DELETE FROM author_books
WHERE author_id = ?
  AND books_order = ?
Binding:[1, 2]

UPDATE author_books
SET books_id = ?
WHERE author_id = ?
AND books_order = ?
Binding:[3, 1, 1]

UPDATE author_books
SET books_id = ?
WHERE author_id = ?
AND books_order = ?
Binding:[2, 1, 0]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]

离集合的末尾越远,使用@OrderColumn的好处就越小。删除第一本书会导致连接表中出现一个DELETE,后面跟着一串UPDATE语句,意在保持数据库中集合在内存中的顺序。同样,这是没有效率的。

添加@OrderColumn可以为移除操作带来一些好处。然而,要移除的元素越靠近获取列表的头部,需要的UPDATE语句就越多。这会导致性能下降。即使在最好的情况下(从集合的尾部删除一个元素),这种方法也不比双向@OneToMany关联好。

使用@JoinColumn

现在,让我们看看添加@JoinColumn是否会带来任何好处:

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "author_id")
private List<Book> books = new ArrayList<>();

添加@JoinColumn指示 Hibernate】关联能够控制子表外键。换句话说,取消了连接表,表的数量从三个减少到两个,如图 1-4 所示。

img/487471_1_En_1_Fig4_HTML.jpg

图 1-4

添加@JoinColumn 会删除连接表

坚持作者和书籍

通过insertAuthorWithBooks()服务方法持久化作者和相关书籍会触发以下相关的 SQL 语句:

INSERT INTO author (age, genre, name)
  VALUES (?, ?, ?)
Binding:[34, History, Joana Nimar]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[001-JN, A History of Ancient Prague]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[002-JN, A People's History]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[003-JN, World History]

-- additional DML that are not needed in bidirectional @OneToMany
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 1]

UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 2]

UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 3]

因此,对于每一本插入的书,Hibernate 都会触发一个UPDATE来设置author_id值。显然,与双向@OneToMany关联相比,这增加了性能损失。

坚持出版现有作者的新书

通过insertNewBook()服务方法持久化一本新书会触发以下相关的 SQL 语句:

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding:[004-JN, History Details]

-- additional DML that is not needed in bidirectional @OneToMany
UPDATE book
SET author_id = ?
WHERE id = ?
Binding:[1, 4]

这并不像常规的单向@OneToMany关联那样糟糕,但是它仍然需要一个在双向@OneToMany关联中不需要的UPDATE语句。

删除最后一本书

通过deleteLastBook()删除最后一本书会触发以下相关 SQL 语句:

UPDATE book
SET author_id = NULL
WHERE author_id = ?
AND id = ?
Binding:[1, 3]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[3]

JPA 持久性提供者(Hibernate)通过将author_id设置为null来将图书与其作者分离。

接下来,由于orhpanRemoval=true,被解除关联的书被删除。然而,这个额外的UPDATE对于双向@OneToMany关联来说是不必要的。

删除第一本书

通过deleteFirstBook()删除第一本书会触发以下相关的 SQL 语句(这些语句与上一小节中的 SQL 语句相同):

UPDATE book
SET author_id = NULL
WHERE author_id = ?
AND id = ?
Binding:[1, 1]

-- for bidirectional @OneToMany this is the only needed DML
DELETE FROM book
WHERE id = ?
Binding:[1]

那个UPDATE还在!再次,双向@OneToMany协会赢得这场比赛。

添加@JoinColumn可以提供优于常规单向@OneToMany的好处,但并不比双向@OneToMany关联好。额外的UPDATE语句仍然会导致性能下降。

同时加@JoinColumn@OrderColumn还是比不上双向@OneToMany。此外,使用Set而不是List或者双向@OneToMany@JoinColumn(例如@ManyToOne @JoinColumn(name = "author_id", updatable = false, insertable = false))仍然比双向@OneToMany关联的性能差。

根据经验,单向@OneToMany关联不如双向@OneToMany或单向@ManyToOne关联有效。

完整的代码可以在 GitHub 2 上找到。

第三条:单向@ManyToOne 的效率如何

第 2 项所强调的,单向@OneToMany关联效率不高,双向@OneToMany关联更好。但是,单向@ManyToOne协会的效率如何呢?让我们假设AuthorBook参与了单向懒惰@ManyToOne关联。@ManyToOne关联正好映射到一对多表关系,如图 1-5 所示。

img/487471_1_En_1_Fig5_HTML.jpg

图 1-5

一对多表关系

如您所见,底层外键处于子端控制之下。这对于单向或双向关系是一样的。

在代码中,AuthorBook实体如下:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;
    ...
}

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String isbn;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;
    ...
}

现在,让我们看看单向@ManyToOne关联的效率有多高。

给某个作者添加新书

向某个作者添加新书的最有效方式如下例所示(为简洁起见,我们简单地将作者id硬编码为4):

@Transactional
public void insertNewBook() {
    Author author = authorRepository.getOne(4L);

    Book book = new Book();
    book.setIsbn("003-JN");
    book.setTitle("History Of Present");
    book.setAuthor(author);

    bookRepository.save(book);
}

这个方法将触发一个单独的INSERT SQL 语句。author_id列将由关联的Author实体的标识符填充:

INSERT INTO book (author_id, isbn, title)
  VALUES (?, ?, ?)
Binding:[4, 003-JN, History Of Present]

注意,我们使用了getOne()方法,该方法通过EntityManager.getReference()返回一个Author引用(更多细节可在项目 14 中找到)。引用状态可能是延迟获取的,但是在这个上下文中不需要它。因此,您避免了不必要的SELECT语句。当然,如果您需要在持久性上下文中实际加载Author实例,依赖findById()也是可能的和可取的。显然,这将通过SELECT语句来实现。

Hibernate 脏检查机制按预期工作(如果你不熟悉 Hibernate 脏检查,那么考虑第 18 项)。换句话说,更新book将导致代表您触发UPDATE语句。查看以下代码:

@Transactional
public void insertNewBook() {
    Author author = authorRepository.getOne(4L);

    Book book = new Book();
    book.setIsbn("003-JN");
    book.setTitle("History Of Present");
    book.setAuthor(author);

    bookRepository.save(book);

    book.setIsbn("not available");
}

这一次,调用insertNewBook()将触发一个INSERT和一个UPDATE:

INSERT INTO book (author_id, isbn, title)
  VALUES (?, ?, ?)

UPDATE book
SET author_id = ?,
    isbn = ?,
    title = ?
WHERE id = ?

因为 Hibernate 用相关联的Author实体的标识符填充author_id列,所以向某个作者添加新书是高效的。

获取作者的所有书籍

您可以通过 JPQL 查询获取作者写的所有书籍,如下所示:

@Transactional(readOnly = true)
@Query("SELECT b FROM Book b WHERE b.author.id = :id")
List<Book> fetchBooksOfAuthorById(Long id);

从服务方法调用fetchBooksOfAuthorById()非常简单:

public void fetchBooksOfAuthorById() {
    List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);
}

触发的SELECT如下图所示:

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i4_1_,
  book0_.isbn AS isbn2_1_,
  book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?

修改一本书将利用脏检查机制。换句话说,从这个集合中更新一本书将导致代表您触发一个UPDATE语句。查看以下代码:

@Transactional
public void fetchBooksOfAuthorById() {
    List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);

    books.get(0).setIsbn("not available");
}

这一次,调用fetchBooksOfAuthorById()将触发一个SELECT和一个UPDATE:

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i4_1_,
  book0_.isbn AS isbn2_1_,
  book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?

UPDATE book
SET author_id = ?,
    isbn = ?,
    title = ?

WHERE id = ?

获取一个作者的所有书籍只需要一个SELECT;因此,该操作是高效的。Hibernate 不管理获取的集合,但是添加/删除书籍是非常有效和容易完成的。这个话题马上就要谈到了。

给作者的书翻页

只要子记录的数量很少,获取所有的书就可以了。一般来说,获取大型集合肯定是一种糟糕的做法,会导致严重的性能损失。分页的作用如下(只需添加一个Pageable参数来产生一个经典的 Spring 数据偏移分页):

@Transactional(readOnly = true)
@Query("SELECT b FROM Book b WHERE b.author.id = :id")
Page<Book> fetchPageBooksOfAuthorById(Long id, Pageable pageable);

您可以从服务方法中调用fetchPageBooksOfAuthorById(),如下例所示(当然,实际上,您不会使用这里所示的硬编码值):

public void fetchPageBooksOfAuthorById() {
    Page<Book> books = bookRepository.fetchPageBooksOfAuthorById(4L,
        PageRequest.of(0, 2, Sort.by(Sort.Direction.ASC, "title")));

    books.get().forEach(System.out::println);
}

该方法触发两个SELECT语句:

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i4_1_,
  book0_.isbn AS isbn2_1_,
  book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?
ORDER BY book0_.title ASC LIMIT ?

SELECT
  COUNT(book0_.id) AS col_0_0_
FROM book book0_
WHERE book0_.author_id = ?

优化偏移分页可按项 95项 96 进行。

与上一节完全一样,Hibernate 不管理获取的集合,但是修改一本书将利用脏检查机制。

获取作者的所有书籍并添加新书

“获取某个作者的所有书籍”一节已经涵盖了这个主题的一半,而“向某个作者添加新书”一节则涵盖了另一半。连接这些部分会产生以下代码:

@Transactional
public void fetchBooksOfAuthorByIdAndAddNewBook() {
    List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);

    Book book = new Book();
    book.setIsbn("004-JN");
    book.setTitle("History Facts");
    book.setAuthor(books.get(0).getAuthor());

    books.add(bookRepository.save(book));
}

触发的 SQL 语句有:

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i4_1_,
  book0_.isbn AS isbn2_1_,
  book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?

INSERT INTO book (author_id, isbn, title)
  VALUES (?, ?, ?)

由于获取一个作者的所有书籍只需要一个SELECT,向获取的集合中添加一本新书只需要一个INSERT,所以这个操作是高效的。

获取作者的所有书籍并删除一本书

以下代码获取某个作者的所有书籍,并删除第一本书:

@Transactional
public void fetchBooksOfAuthorByIdAndDeleteFirstBook() {
    List<Book> books = bookRepository.fetchBooksOfAuthorById(4L);

    bookRepository.delete(books.remove(0));
}

除了众所周知的获取作者所有书籍所需的SELECT之外,删除发生在单个DELETE语句中,如下所示:

DELETE FROM book
WHERE id = ?

因为获取一个作者的所有书籍只需要一个SELECT,而从获取的集合中删除一本书只需要一个DELETE,所以这个操作是高效的。

看起来单向@ManyToOne关联非常有效,只要不需要双向@OneToMany关联就可以使用。再次,尽量避免单向的@OneToMany联想(见第 2 项)。

完整的应用可在 GitHub 3 上获得。

第四项:如何有效塑造@ManyToMany 协会

这一次,众所周知的AuthorBook实体参与了一个双向的懒惰@ManyToMany关联(一个作者写了更多的书,一本书被几个作者写了)。见图 1-6 。

img/487471_1_En_1_Fig6_HTML.jpg

图 1-6

@ManyToMany 表关系

双向@ManyToMany关联可以从两端导航,因此,两端都可以是父级(父级端)。由于双方都是父母,他们都不会持有外键。在这个关联中,有两个外键存储在一个单独的表中,称为连接表。连接表是隐藏的,它扮演子端的角色。

编写双向@ManyToMany关联的最佳方式将在以下章节中描述。

选择关系的所有者

使用默认的@ManyToMany映射需要开发人员选择关系的所有者和mappedBy方(也就是相反的一方)。只有一端可以是所有者,并且更改仅从该特定端传播到数据库。比如Author可以当主人,而Book加一个mappedBy方。

@ManyToMany(mappedBy = "books")
private Set<Author> authors = new HashSet<>();

总是使用集合而不是列表

特别是如果涉及移除操作,建议依赖Set并避免List。正如第 5 项所强调的,Set的表现要比List好得多。

private Set<Book> books = new HashSet<>();     // in Author
private Set<Author> authors = new HashSet<>(); // in Book

保持关联双方的同步

通过在您更可能与之交互的一侧添加辅助方法,您可以很容易地使关联的两端保持同步。例如,如果业务逻辑对操纵Author比对Book更感兴趣,那么开发人员可以将Author添加到至少这三个助手中:addBook()removeBook()removeBooks()

避免级联类型。ALL 和 CascadeType。移动

在大多数情况下,级联删除是坏主意。例如,删除一个Author实体不应该触发Book删除,因为Book也可以被其他作者引用(一本书可以由几个作者写)。所以,避开CascadeType.ALLCascadeType.REMOVE,依靠显式的CascadeType.PERSISTCascadeType.MERGE:

@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<Book> books = new HashSet<>();

孤儿移除(orphanRemoval)选项在@OneToOne@OneToMany关系注释上定义,但在@ManyToOne@ManyToMany注释上都没有定义。

设置连接表

显式设置连接表名和列名允许开发人员引用它们而不会混淆。这可以通过@JoinTable完成,如下例所示:

@JoinTable(name = "author_book",
          joinColumns = @JoinColumn(name = "author_id"),
          inverseJoinColumns = @JoinColumn(name = "book_id")
)

在关联的两端使用延迟抓取

默认情况下,@ManyToMany关联是懒惰的。保持这种方式!不要这样做:

@ManyToMany(fetch=FetchType.EAGER)

重写 equals()和 hashCode()

通过适当地覆盖equals()hashCode()方法,应用在所有实体状态转换中获得相同的结果。这方面在第 68 项中有所剖析。对于双向的@ManyToMany关联,这些方法应该在两端都被覆盖。

注意 toString()是如何被覆盖的

如果toString()需要被覆盖,只涉及从数据库加载实体时提取的基本属性。涉及惰性属性或关联将触发单独的 SQL 语句来获取相应的数据。

作者和书籍示例

将这些指令粘合在一起并用代码表达它们将会产生下面的AuthorBook示例:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(name = "author_book",
              joinColumns = @JoinColumn(name = "author_id"),
              inverseJoinColumns = @JoinColumn(name = "book_id")
    )
    private Set<Book> books = new HashSet<>();

    public void addBook(Book book) {
        this.books.add(book);
        book.getAuthors().add(this);
    }

    public void removeBook(Book book) {
        this.books.remove(book);
        book.getAuthors().remove(this);
    }

    public void removeBooks() {
        Iterator<Book> iterator = this.books.iterator();

        while (iterator.hasNext()) {
            Book book = iterator.next();

            book.getAuthors().remove(this);
            iterator.remove();
        }
    }

    // getters and setters omitted for brevity

    @Override
    public boolean equals(Object obj) {

        if(obj == null) {
            return false;
        }

        if (this == obj) {
            return true;
        }

        if (getClass() != obj.getClass()) {
            return false;
        }

        return id != null && id.equals(((Author) obj).id);
    }

    @Override
    public int hashCode() {
        return 2021;
    }

    @Override
    public String toString() {
        return "Author{" + "id=" + id + ", name=" + name
                      + ", genre=" + genre + ", age=" + age + '}';
    }
}

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String isbn;

    @ManyToMany(mappedBy = "books")
    private Set<Author> authors = new HashSet<>();

    // getters and setter omitted for brevity

    @Override
    public boolean equals(Object obj) {

        if(obj == null) {
            return false;
        }

        if (this == obj) {
            return true;
        }

        if (getClass() != obj.getClass()) {
            return false;
        }

        return id != null && id.equals(((Book) obj).id);
    }

    @Override
    public int hashCode() {
        return 2021;
    }

    @Override
    public String toString() {
        return "Book{" + "id=" + id + ", title=" + title
                                     + ", isbn=" + isbn + '}';
    }
}

GitHub 4 上有源代码。

或者,@ManyToMany可以用两个双向@OneToMany关联代替。换句话说,连接表可以映射到一个实体。这带来了几个好处,本文讨论了。

第五条:为什么在@ManyToMany 中 Set 比 List 好

首先,请记住 Hibernate 将@ManyToMany关系作为两个单向@OneToMany关联来处理。所有者端和子端(连接表)代表一个单向的@OneToMany关联。另一方面,非所有者端和子端(连接表)代表另一种单向@OneToMany关联。每个关联依赖于存储在连接表中的外键。

在该语句的上下文中,实体移除(或重新排序)导致从连接表中删除所有连接条目,并重新插入它们以反映内存内容(当前持久上下文内容)。

使用列表

假设双向惰性@ManyToMany关联中涉及的AuthorBook通过java.util.List进行映射,如下所示(仅列出相关代码):

@Entity
public class AuthorList implements Serializable {
    ...
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(name = "author_book_list",
              joinColumns = @JoinColumn(name = "author_id"),
              inverseJoinColumns = @JoinColumn(name = "book_id")
    )
    private List<BookList> books = new ArrayList<>();
    ...
}

@Entity
public class BookList implements Serializable {
    ...
    @ManyToMany(mappedBy = "books")
    private List<AuthorList> authors = new ArrayList<>();
    ...
}

此外,考虑图 1-7 中所示的数据快照。

img/487471_1_En_1_Fig7_HTML.jpg

图 1-7

数据快照(双向@ManyToMany)

目标是在某一天移除作者艾丽西娅·汤姆(ID 为1的作者)写的那本名为的书(ID 为 2 的书)。考虑到代表这个作者的实体是通过一个名为alicia的变量存储的,而书是通过一个名为oneDay的变量存储的,所以可以通过removeBook()进行删除,如下所示:

alicia.removeBook(oneDay);

此删除触发的 SQL 语句有:

DELETE FROM author_book_list
WHERE author_id = ?
Binding: [1]

INSERT INTO author_book_list (author_id, book_id)
  VALUES (?, ?)
Binding: [1, 1]

INSERT INTO author_book_list (author_id, book_id)
  VALUES (?, ?)
Binding: [1, 3]

因此,删除并不是在一条 SQL 语句中实现的。实际上,它是从从连接表中删除alicia的所有连接条目开始的。此外,没有被删除的连接条目被重新插入,以反映内存中的内容(持久性上下文)。重新插入的连接条目越多,数据库事务就越长。

使用集合

考虑从List切换到Set,如下所示:

@Entity
public class AuthorSet implements Serializable {
    ...
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(name = "author_book_set",
              joinColumns = @JoinColumn(name = "author_id"),
              inverseJoinColumns = @JoinColumn(name = "book_id")
    )
    private Set<BookSet> books = new HashSet<>();
    ...
}

@Entity
public class BookSet implements Serializable {
    ...
    @ManyToMany(mappedBy = "books")
    private Set<AuthorSet> authors = new HashSet<>();
    ...
}

这一次,调用alicia.removeBook(oneDay)将触发下面的 SQL DELETE语句:

DELETE FROM author_book_set
WHERE author_id = ?
  AND book_id = ?
Binding: [1, 2]

源代码可以在 GitHub 6 上找到。这要好得多,因为只需要一条DELETE语句就可以完成这项工作。

当使用@ManyToMany注释时,总是使用java.util.Set。不要使用java.util.List。在其他关联的情况下,使用最适合您情况的一个。如果你选择了List,不要忘了注意 HHH-58557的问题,这个问题从 Hibernate 5.0.8 开始就被修复了。

保留结果集的顺序

众所周知,java.util.ArrayList保留了插入元素的顺序(它可以精确控制每个元素在列表中的插入位置),而java.util.HashSet则不能。换句话说,java.util.ArrayList有一个预定义的元素输入顺序,而java.util.HashSet在默认情况下是无序的。

至少有两种方法可以根据 JPA 规范定义的给定列对结果集进行排序:

  • 使用@OrderBy请求数据库按照给定的列对获取的数据进行排序(在生成的 SQL 查询中附加ORDER BY子句,以特定的顺序检索实体),并使用 Hibernate 来保持这种顺序。

  • 使用@OrderColumn通过一个额外的列(在这种情况下,存储在连接表中)对此进行永久排序。

该注释(@OrderBy)可以与@OneToMany/@ManyToMany关联和@ElementCollection一起使用。添加没有显式列的@OrderBy将导致实体按其主键(ORDER BY author1_.id ASC)升序排序。按多列排序也是可能的(例如,按年龄降序和按姓名升序排序,@OrderBy("age DESC, name ASC"))。显然,@OrderBy也可以和java.util.List一起使用。

使用@OrderBy

考虑图 1-8 中的数据快照。

img/487471_1_En_1_Fig8_HTML.jpg

图 1-8

数据快照(多对多集和@OrderBy)

有一本由六位作者写的书。目标是通过Book#getAuthors()按名字降序获取作者。这可以通过在Book中添加@OrderBy来实现,如下所示:

@ManyToMany(mappedBy = "books")
@OrderBy("name DESC")
private Set<Author> authors = new HashSet<>();

getAuthors()被调用时,@OrderBy将:

  • 将相应的ORDER BY子句附加到触发的 SQL。这将指示数据库对提取的数据进行排序。

  • 发送 Hibernate 信号以保持顺序。在后台,Hibernate 将通过一个LinkedHashSet来保存顺序。

因此,调用getAuthors()将导致符合@OrderBy信息的Set个作者。被触发的 SQL 是下面包含ORDER BY子句的SELECT:

SELECT
  authors0_.book_id AS book_id2_1_0_,
  authors0_.author_id AS author_i1_1_0_,
  author1_.id AS id1_0_1_,
  author1_.age AS age2_0_1_,
  author1_.genre AS genre3_0_1_,
  author1_.name AS name4_0_1_
FROM author_book authors0_
INNER JOIN author author1_
  ON authors0_.author_id = author1_.id
WHERE authors0_.book_id = ?
ORDER BY author1_.name DESC

显示Set将输出以下内容(通过Author#toString()):

Author{id=2, name=Quartis Young, genre=Anthology, age=51},
Author{id=6, name=Qart Pinkil, genre=Anthology, age=56},
Author{id=5, name=Martin Leon, genre=Anthology, age=38},
Author{id=1, name=Mark Janel, genre=Anthology, age=23},
Author{id=4, name=Katy Loin, genre=Anthology, age=56},
Author{id=3, name=Alicia Tom, genre=Anthology, age=38}

GitHub 8 上有源代码。

@OrderByHashSet一起使用将保持加载/获取Set的顺序,但这在整个瞬态中并不一致。如果这是一个问题,为了获得瞬态的一致性,考虑显式地使用LinkedHashSet而不是HashSet。因此,为了完全一致,请使用:

@ManyToMany(mappedBy = "books")
@OrderBy("name DESC")
private Set<Author> authors = new LinkedHashSet<>();

第 6 项:为什么以及何时避免删除 CascadeType 的子实体。Remove 和 orphanRemoval = true

首先,我们快速突出一下CascadeType.REMOVEorphanRemoval=true的区别。让我们使用双向懒惰@OneToMany关联中涉及的AuthorBook实体,编写如下:

// in Author.java
@OneToMany(cascade = CascadeType.ALL,
          mappedBy = "author", orphanRemoval = true)
private List<Book> books = new ArrayList<>();

// in Book.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;

删除一个Author实体会自动级联到相关的Book实体。只要CascadeType.REMOVEorphanRemoval=true存在,就会发生这种情况。换句话说,从这个角度来看,两者的存在都是多余的。

那他们有什么不同?好吧,考虑下面这个用来断开(或分离)一个Book和它的Author的助手方法:

public void removeBook(Book book) {
    book.setAuthor(null);
    this.books.remove(book);
}

或者,断开所有Book与其Author的连接:

public void removeBooks() {
Iterator<Book> iterator = this.books.iterator();

    while (iterator.hasNext()) {
        Book book = iterator.next();

        book.setAuthor(null);
        iterator.remove();
    }
}

在有orphanRemoval=true的情况下调用removeBook()方法将导致通过DELETE语句自动移除book。在orphanRemoval=false面前调用它将触发UPDATE声明。因为断开Book不是移除操作,所以CascadeType.REMOVE的存在无关紧要。因此,orphanRemoval=true对于清理那些没有所有者实体(Author)的引用就不应该存在的实体(删除悬空引用)很有用。

但是这些设置的效率如何呢?简短的回答是:如果它们必须影响大量的实体,那么效率就不是很高。长答案从删除以下服务方法中的一个作者开始(这个作者有三本相关的书):

@Transactional
public void deleteViaCascadeRemove() {
    Author author = authorRepository.findByName("Joana Nimar");

    authorRepository.delete(author);
}

删除作者会将删除级联到关联的图书。这是CascadeType.ALL的效果,包含了CascadeType.REMOVE。但是,在删除相关书籍之前,它们通过一个SELECT被加载到持久性上下文中。如果它们已经在持久性上下文中,则不会被加载。如果书籍不存在于持久上下文中,那么CascadeType.REMOVE将不会生效。此外,有四个DELETE语句,一个用于删除作者,三个用于删除相关书籍:

DELETE
FROM book
WHERE id=?
Binding:[1]

DELETE
FROM book
WHERE id=?
Binding:[2]

DELETE
FROM book
WHERE id=?
Binding:[4]

DELETE
FROM author
WHERE id=?
Binding:[4]

每本书都有单独的DELETE陈述。要删除的书籍越多,拥有的DELETE语句就越多,性能损失就越大。

现在让我们编写一个基于orphanRemoval=true删除的服务方法。为了变化,这一次,我们将作者和相关书籍加载在同一个SELECT:

@Transactional
public void deleteViaOrphanRemoval() {
    Author author = authorRepository.findByNameWithBooks("Joana Nimar");

    author.removeBooks();
    authorRepository.delete(author);
}

不幸的是,这种方法将触发与级联删除完全相同的DELETE语句,因此它倾向于相同的性能损失。

如果您的应用触发了零星的删除,您可以依靠CascadeType.REMOVE和/或orphanRemoval=true。这在删除托管实体时尤其有用,因此需要 Hibernate 来管理实体的状态转换。此外,通过这种方法,你可以从父母和孩子的自动乐观锁定机制(如@Version)中受益。但是,如果您只是在寻找更有效的删除方法(在更少的 DML 语句中),我们将考虑其中的一些方法。当然,每种方法都有自己的权衡。

以下四种方法通过批量操作删除作者和相关书籍。这样,您可以优化和控制触发的DELETE语句的数量。这些操作非常快,但是它们有三个主要缺点:

  • 他们忽略了自动乐观锁定机制(例如,你不能再依赖于@Version)

  • 持久性上下文没有被同步以反映由批量操作执行的修改,这可能导致过时的上下文

  • 他们没有利用级联删除(CascadeType.REMOVE)或orphanRemoval

如果这些缺点对你很重要,你有两个选择:避免批量操作或者明确地处理这个问题。最困难的部分是为没有加载到持久性上下文中的子进程模拟自动乐观锁定机制的工作。以下示例假设没有启用自动乐观锁定机制。然而,它们通过flushAutomatically = trueclearAutomatically = true管理持久性上下文同步问题。不要认为这两个设置总是需要的。它们的用法取决于你想要达到的目的。

删除已经加载到持久性上下文中的作者

让我们来处理这样的情况:在持久性上下文中,只有一个Author被加载,以及有更多的Author被加载,但不是所有的。还必须删除相关的书籍(已经或尚未加载到持久性上下文中)。

已经在持久性上下文中加载了一个作者

让我们假设应该被删除的Author在没有它们的关联Book的情况下在持久性上下文中被更早地加载。要删除这个Author和相关的书籍,可以使用作者标识符(author.getId())。首先,删除所有与作者相关的书籍:

// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author.id = ?1")
public int deleteByAuthorIdentifier(Long id);

然后,让我们按作者的标识符删除作者:

// add this method in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a.id = ?1")
public int deleteByIdentifier(Long id);

稍后会解释flushAutomatically = true, clearAutomatically = true的存在。目前,负责触发删除的服务方法是:

@Transactional
public void deleteViaIdentifiers() {
    Author author = authorRepository.findByName("Joana Nimar");

    bookRepository.deleteByAuthorIdentifier(author.getId());
    authorRepository.deleteByIdentifier(author.getId());
}

调用deleteViaIdentifiers()会触发以下查询:

DELETE FROM book
WHERE author_id = ?

DELETE FROM author
WHERE id = ?

注意,相关的书籍没有被加载到持久性上下文中,只有两个DELETE语句被触发。书的数量不影响DELETE语句的数量。

也可以通过内置的deleteInBatch(Iterable<T> entities)删除作者:

authorRepository.deleteInBatch(List.of(author));

持久性上下文中加载了更多的作者

让我们假设持久性上下文包含更多应该被删除的Author。例如,让我们删除所有作为List<Author>获取的年龄为 34Author(让我们假设有两个年龄为 34 的作者)。尝试按作者标识符删除(如前一种情况)将导致每个作者有一个单独的DELETE。此外,每个作者的相关书籍将有一个单独的DELETE。所以这样效率不高。

这一次,让我们依靠两架散装作战。一个由您通过IN操作符(允许您在一个WHERE子句中指定多个值)和内置的deleteInBatch(Iterable<T> entities)定义:

// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author IN ?1")
public int deleteBulkByAuthors(List<Author> authors);

删除List<Author>和相关Book的服务方法如下:

@Transactional
public void deleteViaBulkIn() {
    List<Author> authors = authorRepository.findByAge(34);

    bookRepository.deleteBulkByAuthors(authors);
    authorRepository.deleteInBatch(authors);
}

调用deleteViaBulkIn()会触发以下查询:

DELETE FROM book
WHERE author_id IN (?, ?)

DELETE FROM author
WHERE id = ?
  OR id = ?

注意,相关的书籍没有被加载到持久性上下文中,只有两个DELETE语句被触发。作者和书籍的数量不影响DELETE语句的数量。

一位作者和他的相关书籍已经被加载到持久性上下文中

假设Author(应该被删除的那个)及其关联的Book已经被加载到持久性上下文中。这一次没有必要定义批量操作,因为内置的deleteInBatch(Iterable<T> entities)可以为您完成这项工作:

@Transactional
public void deleteViaDeleteInBatch() {
    Author author = authorRepository.findByNameWithBooks("Joana Nimar");

    bookRepository.deleteInBatch(author.getBooks());
    authorRepository.deleteInBatch(List.of(author));
}

这里的主要缺点是内置deleteInBatch(Iterable<T> entities)的默认行为,默认情况下,它不会刷新或清除持久性上下文。这可能会使持久性上下文处于过时状态。

当然,在前面的方法中,在删除之前不需要刷新任何内容,也不需要清除持久性上下文,因为在删除操作之后,事务会提交。因此,持久性上下文是封闭的。但是,在某些情况下,冲洗和清洁(不一定两者都需要)是必需的。通常,清除操作比刷新操作更需要。例如,下面的方法在删除之前不需要刷新,但是在删除之后需要清除。否则会导致异常:

@Transactional
public void deleteViaDeleteInBatch() {
    Author author = authorRepository.findByNameWithBooks("Joana Nimar");

    bookRepository.deleteInBatch(author.getBooks());
    authorRepository.deleteInBatch(List.of(author));

    ...

    // later on, we forgot that this author was deleted
    author.setGenre("Anthology");
}

突出显示的代码将导致以下类型的异常:

org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [com.bookstore.entity.Author] with identifier [4]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.bookstore.entity.Author#4]

实际上,修改(setGenre()的调用)改变了持久上下文中包含的Author实体,但是这个上下文已经过时了,因为作者已经从数据库中删除了。换句话说,从数据库中删除作者和相关书籍后,它们将继续存在于当前的持久性上下文中。持久性上下文不知道通过deleteInBatch(Iterable<T> entities)执行的删除。为了确保删除后持久性上下文被清除,您可以覆盖deleteInBatch(Iterable<T> entities)来添加@Modifying(clearAutomatically = true)。这样,持久性上下文在删除后会自动清除。如果你在一个用例中也需要一个预先刷新,那么使用@Modifying(flushAutomatically = true, clearAutomatically = true)或者调用flush()方法。或者,更好的是,您可以重用deleteViaIdentifiers()方法,如下所示(我们已经用@Modifying(flushAutomatically = true, clearAutomatically = true)注释了这个方法):

@Transactional
public void deleteViaIdentifiers() {
    Author author = authorRepository.findByNameWithBooks("Joana Nimar");

    bookRepository.deleteByAuthorIdentifier(author.getId());
    authorRepository.deleteByIdentifier(author.getId());
}

调用deleteViaIdentifiers()会触发以下查询:

DELETE FROM book
WHERE author_id = ?

DELETE FROM author
WHERE id = ?

书的数量不影响DELETE语句的数量。

如果持久化上下文管理几个应该被删除的Author和相关的Book,那么依赖于deleteViaBulkIn()

当应该删除的作者和书籍没有加载到持久性上下文中时删除

如果应该删除的作者及其相关书籍没有加载到持久性上下文中,那么您可以硬编码作者标识符(如果您知道的话),如下面的服务方法所示:

@Transactional
public void deleteViaHardCodedIdentifiers() {
    bookRepository.deleteByAuthorIdentifier(4L);
    authorRepository.deleteByIdentifier(4L);
}

deleteByAuthorIdentifier()deleteByIdentifier()方法与“一个作者已经被加载到持久性上下文中”一节中的方法相同。触发的查询非常明显:

DELETE FROM book
WHERE author_id = ?

DELETE FROM author
WHERE id = ?

如果有更多作者,您可以使用批量操作删除他们:

// add this method in BookRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Book b WHERE b.author.id IN ?1")
public int deleteBulkByAuthorIdentifier(List<Long> id);

// add this method in AuthorRepository
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a.id IN ?1")
public int deleteBulkByIdentifier(List<Long> id);

现在,让我们删除两位作者及其相关书籍:

@Transactional
public void deleteViaBulkHardCodedIdentifiers() {
    List<Long> authorsIds = Arrays.asList(1L, 4L);

    bookRepository.deleteBulkByAuthorIdentifier(authorsIds);
    authorRepository.deleteBulkByIdentifier(authorsIds);
}

触发的 SQL 语句如下:

DELETE FROM book
WHERE author_id IN (?, ?)

DELETE FROM author
WHERE id IN (?, ?)

作者和书籍的数量不影响DELETE语句的数量。由于我们没有在持久性上下文中加载任何东西,flushAutomatically = true, clearAutomatically = true没有任何作用。

为了避免持久性上下文中过时的实体,不要忘记在执行查询(flushAutomatically = true)之前刷新EntityManager,并在执行查询(clearAutomatically = true)之后清除它。如果您不想/不需要刷新和/或清除,那么请注意如何设法避免持久性上下文中的过时实体。只要您知道自己在做什么,不刷新和/或清除持久性上下文是没有问题的。理想情况下,将 批量 操作隔离在专用的事务性服务方法中。这样,就不需要显式地刷新和清除持久性上下文。当您将 批量 操作与受管实体操作交错时,可能会出现问题。

如果您需要复习 flush 的工作原理,请阅读附录 H

删除所有实体最有效的方法是通过内置的deleteAllInBatch(),它触发一个批量操作。

完整的应用可在 GitHub 9 上获得。

第 7 项:如何通过 JPA 实体图获取关联

Item 39

第 41 项描述了如何通过LEFT``JOIN FETCH在同一个SELECT查询中获取与其父节点的关联。这在涉及惰性关联的场景中非常有用,惰性关联应该基于查询急切地获取,以避免惰性加载异常和 N+1 问题。而(LEFT ) JOIN FETCH住在查询里面,实体图是独立于查询的。因此,查询和实体图可以被重用(例如,查询可以与或不与实体图一起使用,而实体图可以与不同的查询一起使用)。

现在,简而言之,JPA 2.1 中引入了实体图(又名提取计划),它们通过解决延迟加载异常和 N+1 问题来帮助您提高加载实体的性能。开发人员指定实体的相关关联和基本字段,它们应该在单个SELECT语句中加载。开发人员可以为同一个实体定义多个实体图,并且可以链接任意数量的实体,甚至可以使用子图来创建复杂的获取计划。实体图是全局的,可以跨实体重用(域模型)。要覆盖当前的FetchType语义,您可以设置两个属性:

  • 取数图:这是默认的取数类型,由javax.persistence.fetchgraph属性表示。出现在attributeNodes中的属性被视为FetchType.EAGER。其余的属性被视为FetchType.LAZY,不管默认/显式FetchType

  • 负载图:该抓取类型可以通过javax.persistence.loadgraph属性使用。出现在attributeNodes中的属性被视为FetchType.EAGER。其余属性根据其指定的或默认的FetchType进行处理。

实体图可以通过注释(如@NamedEntityGraph)、通过attributePaths(特定实体图)、通过调用getEntityGraph()createEntityGraph()方法通过EntityManager API 来定义。

假设AuthorBook实体包含在一个双向惰性@OneToMany关联中。实体图(一个获取图)应该在同一个SELECT中加载所有的Author和相关的Book。同样的事情可以通过JOIN FETCH得到,但是这次让我们通过实体图来做。

通过@NamedEntityGraph 定义实体图

@NamedEntityGraph注释出现在实体级别。通过它的元素,开发人员可以为这个实体图指定一个惟一的名称(通过name元素)和获取实体图时要包含的属性(通过attributeNodes元素,它包含一个由逗号分隔的@NamedAttributeNode注释列表;该列表中的每个@NamedAttributeNode对应于一个应该提取的字段/关联)。属性可以是基本字段和关联。

让我们把实体图放在代码中的Author实体中:

@Entity
@NamedEntityGraph(
    name = "author-books-graph",
    attributeNodes = {
        @NamedAttributeNode("books")
    }
)
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToMany(cascade = CascadeType.ALL,
             mappedBy = "author", orphanRemoval = true)
      private List<Book> books = new ArrayList<>();

    // getters and setters omitted for brevity
}

接下来,关注Author实体的存储库AuthorRepository

AuthorRepository是应该指定实体图形的地方。Spring Data 通过@EntityGraph注释(该注释的类是org.springframework.data.jpa.repository.EntityGraph)提供对实体图形的支持。

覆盖查询方法

例如,使用实体图(author-books-graph)来查找所有Author,包括相关联的Book的代码如下(EntityGraph.EntityGraphType.FETCH是默认的,并指示一个获取图;EntityGraph.EntityGraphType.LOAD可以指定一个负载图):

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Override
    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll();
}

调用findAll()方法将导致下面的 SQL SELECT语句:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id

注意,生成的查询考虑了通过@EntityGraph指定的实体图。

使用查询构建器机制

覆盖findAll()是获取所有实体的一种便捷方式。但是,使用 Spring 数据查询构建器机制通过WHERE子句过滤提取的数据。例如,您可以获取小于给定年龄的作者的实体图,并按姓名降序排列,如下所示:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findByAgeLessThanOrderByNameDesc(int age);
}

生成的 SQL SELECT语句如下所示:

SELECT
  ...
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.age < ?
ORDER BY author0_.name DESC

使用规范

也支持使用Specification。例如,让我们假设下面的经典Specification用于生成WHERE age > 45:

public class AuthorSpecs {
    private static final int AGE = 45;

    public static Specification<Author> isAgeGt45() {
        return (Root<Author> root,
            CriteriaQuery<?> query, CriteriaBuilder builder)
                -> builder.greaterThan(root.get("age"), AGE);
    }
}

让我们用这个Specification:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long>,    JpaSpecificationExecutor<Author> {

    @Override
    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll(Specification spec);
}

List<Author> authors = authorRepository.findAll(isAgeGt45());

生成的 SQL SELECT语句如下:

SELECT
  ...
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.age > 45

使用@Query 和 JPQL

最后,使用@Query和 JPQL 也是可以的。

请注意与指定连接提取的实体图一起使用的查询。在这种情况下,获取的关联的所有者必须出现在SELECT列表中。

查看以下显式 JPQL 查询:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    @Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age < 40")
    public List<Author> fetchAllAgeBetween20And40();
}

SQL SELECT语句如下:

SELECT
  ...
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.age > 20 AND author0_.age < 40

注意使用尝试多次急切抓取的实体图(例如,Author有两个@OneToMany关联声明为LAZY并映射到List,并且都出现在实体图中)。用多个左外连接触发一个SELECT将会急切地获取多个特定于 Hibernate 的Bag(一个无序的集合,其中有重复项,但不打算删除重复项),这将导致MultipleBagFetchException。换句话说,当您使用实体图提示触发一个查询时,如果您尝试多次快速获取,Hibernate 将用一个MultipleBagFetchException响应。

但是,不要假设MultipleBagFetchException是实体图特有的,因为这是一个错误的假设。每当您试图触发一个尝试多次快速获取的查询时,它就会出现。这种异常经常在提取实体层次结构中的多个级别时遇到,例如

这个问题最流行的解决方案是从Set切换到List。虽然这将像预期的那样工作,但它离有效的解决方案还很远,因为合并中间结果集产生的笛卡尔乘积将是巨大的。一般来说,假设您想要获取一些A实体以及它们的BC关联。并且您有 25 个A行与 10 个B行和 20 个C行相关联。用于获取最终结果的笛卡尔积将有 25 x 10 x 20 行= 5000 行!从性能角度来看,这真的很糟糕!最佳解决方案是一次最多获取一个关联。即使这意味着不止一个查询,它也避免了这个巨大的笛卡尔积。完整的例子,请看这篇由弗拉德·米哈尔恰撰写的精彩文章 10

尝试对实体图使用原生查询将导致类型为A native SQL query cannot use EntityGraphs的 Hibernate 异常。

当实体图被转换成获取相关集合的 SQL JOIN时,注意使用分页(Pageable)。在这种情况下,分页发生在内存中,这会导致性能下降。本机查询不能用于实体图。依赖窗口函数( Item 95 )也不是一个选项。除了在WHEREHAVING子句之外编写子查询、执行集合操作(例如UNIONINTERSECTEXCEPT)、使用数据库特定提示和编写递归查询之外,在 JPQL 中使用窗口函数代表了 JPQL 的五大局限性。

另一方面,如果实体图仅获取不是集合的基本(@Basic)属性和/或关联,则分页(Pageable)将由数据库通过LIMIT或对应方来完成。

完整的应用可在 GitHub 11 上获得。

这里有一个非常重要的方面需要注意。实体图(获取图)通过@NamedAttributeNode明确指定只加载books关联。对于获取图形,不管默认/显式FetchType,其余的属性都应被视为FetchType.LAZY。那么为什么前面的查询也包含了Author的基本属性呢?这个问题的答案和解决方案在第 9 项中。参见项目 9 通过实体图(获取和加载图)仅获取所需的基本属性。现在,让我们继续看特设实体图。

即席实体图

可以通过@EntityGraph注释的attributePaths元素定义一个特定的实体图。应该在单个SELECT中加载的实体的相关关联和基本字段被指定为一个列表,由类型为@EntityGraph(attributePaths = {"attr1", "attr2", ...}的逗号分隔。显然,这个时候,没有必要使用@NamedEntityGraph。例如,上一节中的实体图可以写成如下形式:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Override
    @EntityGraph(attributePaths = {"books"},
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll();
}

调用findAll()触发与@NamedEntityGraph相同的 SQL SELECT语句:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id

重复将@EntityGraph与查询构建器机制、Specification和 JPQL 一起使用的示例,将会产生以下存储库:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long>,
        JpaSpecificationExecutor<Author> {

    @Override
    @EntityGraph(attributePaths = {"books"},
        type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll();

    @EntityGraph(attributePaths = {"books"},
        type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findByAgeLessThanOrderByNameDesc(int age);

    @Override
    @EntityGraph(attributePaths = {"books"},
        type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll(Specification spec);

    @EntityGraph(attributePaths = {"books"},
        type = EntityGraph.EntityGraphType.FETCH)
    @Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age<40")
    public List<Author> fetchAllAgeBetween20And40();
}

完整的应用可在 GitHub 12 上获得。

特设实体图是将实体图定义保持在存储库级别并且不使用@NamedEntityGraph改变实体的一种便捷方式。

通过 EntityManager 定义实体图

要通过EntityManager直接获得实体图,您需要调用getEntityGraph(String entityGraphName)方法。接下来,将该方法的返回传递给重载的find()方法,如下面的代码片段所示:

EntityGraph entityGraph = entityManager
                  .getEntityGraph("author-books-graph");

Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.fetchgraph", entityGraph);
Author author = entityManager.find(Author.class, id, properties);

JPQL 和EntityManager也可以使用:

EntityGraph entityGraph = entityManager
                  .getEntityGraph("author-books-graph");

Author author = entityManager.createQuery(
       "SELECT a FROM Author a WHERE a.id = :id", Author.class)
    .setParameter("id", id)
    .setHint("javax.persistence.fetchgraph", entityGraph)
    .getSingleResult();

或者通过CriteriaBuilderEntityManager:

EntityGraph entityGraph = entityManager
                  .getEntityGraph("author-books-graph");

CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<Author> criteriaQuery
    = criteriaBuilder.createQuery(Author.class);

Root<Author> author = criteriaQuery.from(Author.class);
criteriaQuery.where(criteriaBuilder.equal(root.<Long>get("id"), id));

TypedQuery<Author> typedQuery = entityManager.createQuery(criteriaQuery);
typedQuery.setHint("javax.persistence.loadgraph", entityGraph);

Author author = typedQuery.getSingleResult();

您可以通过EntityManager#createEntityGraph()方法创建一个实体图。有关更多详细信息,请阅读文档。

项目 8:如何通过实体子图获取关联

如果您不熟悉实体图,请在此之前阅读第 7 项。

实体图也容易造成性能损失。创建实体的大树(例如,具有子图的子图)或加载不需要的关联(和/或字段)将导致性能损失。想想创建 m x n x p x 类型的笛卡尔乘积有多容易...,很快增长到巨大的价值。

子图允许您构建复杂的实体图。主要地,子图是嵌入到另一个实体图或实体子图中的实体图。让我们看三个实体— AuthorBookPublisherAuthorBook实体包含在一个双向惰性@OneToMany关联中。PublisherBook实体也包含在双向惰性@OneToMany关联中。在AuthorPublisher之间没有关联。图 1-9 显示了涉及的表格(authorbookpublisher)。

img/487471_1_En_1_Fig9_HTML.jpg

图 1-9

表关系

这个实体图的目标是获取所有相关书籍的作者,以及与这些书籍相关的出版商。为此,让我们使用实体子图。

使用@NamedEntityGraph 和@NamedSubgraph

Author实体中,使用@NamedEntityGraph定义实体图以急切地加载作者和相关书籍,使用@NamedSubgraph定义实体子图以加载与已加载书籍相关的出版商:

@Entity
@NamedEntityGraph(
    name = "author-books-publisher-graph",
    attributeNodes = {
        @NamedAttributeNode(value = "books", subgraph = "publisher-subgraph")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "publisher-subgraph",
            attributeNodes = {
                @NamedAttributeNode("publisher")
            }
        )
    }
)
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToMany(cascade = CascadeType.ALL,
               mappedBy = "author", orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    // getters and setters omitted for brevity
}

这里列出了Book中的相关部分:

@Entity
public class Book implements Serializable {
    ...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private Publisher publisher;
    ...
}

进一步,让我们使用AuthorRepository中的实体图:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Override
    @EntityGraph(value = "author-books-publisher-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll();
}

调用findAll()会触发下面的 SQL SELECT语句:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  publisher2_.id AS id1_2_2_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.publisher_id AS publishe5_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__,
  publisher2_.company AS company2_2_2_
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
LEFT OUTER JOIN publisher publisher2_
  ON books1_.publisher_id = publisher2_.id

虽然这很明显,但是让我们提一下子图可以与查询构建器机制、Specification和 JPQL 一起使用。例如,下面是 JPQL 使用的子图:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @EntityGraph(value = "author-books-publisher-graph",
                 type = EntityGraph.EntityGraphType.FETCH)
    @Query(value="SELECT a FROM Author a WHERE a.age > 20 AND a.age<40")
    public List<Author> fetchAllAgeBetween20And40();
}

调用fetchAllAgeBetween20And40()会触发下面的 SQL SELECT语句(注意查询是如何被丰富为实体图的):

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  publisher2_.id AS id1_2_2_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.publisher_id AS publishe5_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__,
  publisher2_.company AS company2_2_2_
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
LEFT OUTER JOIN publisher publisher2_
  ON books1_.publisher_id = publisher2_.id
WHERE author0_.age > 20
AND author0_.age < 40

注意 JPQL 查询与指定连接提取的实体图一起使用。在这样的 JPQL 查询中,被提取的关联的所有者必须出现在SELECT列表中。

使用点符号(。)在特定实体图中

子图也可以用在特定的实体图中。请记住,特设实体图允许您将实体图定义保持在存储库级别,并且不使用@NamedEntityGraph更改实体。

要使用子图,您只需使用点符号(.),如下例所示:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Override
    @EntityGraph(attributePaths = {"books.publisher"},
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findAll();
}

因此,您可以通过books.publisher路径获取与图书相关的出版商。触发的SELECT与使用@NamedEntityGraph@NamedSubgraph时相同。

让我们看另一个例子,只是为了熟悉这个想法。让我们定义一个特定的实体图来获取所有出版商和相关书籍,以及与这些书籍相关的作者。这次,实体图在PublisherRepository中定义如下:

@Repository
@Transactional(readOnly = true)
public interface PublisherRepository
    extends JpaRepository<Publisher, Long> {

    @Override
    @EntityGraph(attributePaths = "books.author"},
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Publisher> findAll();
}

本次触发的 SQL SELECT语句如下:

SELECT
  publisher0_.id AS id1_2_0_,
  books1_.id AS id1_1_1_,
  author2_.id AS id1_0_2_,
  publisher0_.company AS company2_2_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.publisher_id AS publishe5_1_1_,
  books1_.title AS title3_1_1_,
  books1_.publisher_id AS publishe5_1_0__,
  books1_.id AS id1_1_0__,
  author2_.age AS age2_0_2_,
  author2_.genre AS genre3_0_2_,
  author2_.name AS name4_0_2_
FROM publisher publisher0_
LEFT OUTER JOIN book books1_
  ON publisher0_.id = books1_.publisher_id
LEFT OUTER JOIN author author2_
  ON books1_.author_id = author2_.id

特设子图可以与 Spring 数据查询构建器机制、Specification和 JPQL 一起使用。例如,这里是上面与 JPQL 一起使用的特殊子图:

@Repository
@Transactional(readOnly = true)
public interface PublisherRepository
    extends JpaRepository<Publisher, Long> {

    @EntityGraph(attributePaths = {"books.author"},
                 type = EntityGraph.EntityGraphType.FETCH)
    @Query("SELECT p FROM Publisher p WHERE p.id > 1 AND p.id < 3")
    public List<Publisher> fetchAllIdBetween1And3();
}

调用fetchAllIdBetween1And3()会触发下面的 SQL SELECT语句(注意查询是如何被丰富为实体图的):

SELECT
  publisher0_.id AS id1_2_0_,
  books1_.id AS id1_1_1_,
  author2_.id AS id1_0_2_,
  publisher0_.company AS company2_2_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.publisher_id AS publishe5_1_1_,
  books1_.title AS title3_1_1_,
  books1_.publisher_id AS publishe5_1_0__,
  books1_.id AS id1_1_0__,
  author2_.age AS age2_0_2_,
  author2_.genre AS genre3_0_2_,
  author2_.name AS name4_0_2_
FROM publisher publisher0_
LEFT OUTER JOIN book books1_
  ON publisher0_.id = books1_.publisher_id
LEFT OUTER JOIN author author2_
  ON books1_.author_id = author2_.id
WHERE publisher0_.id > 1
AND publisher0_.id < 3

完整的应用可在 GitHub 13 上获得。

通过 EntityManager 定义实体子图

您可以通过EntityManagerEntityGraph.addSubgraph(String attributeName)方法直接构建一个实体子图,如下面的代码片段所示:

EntityGraph<Author> entityGraph = entityManager.createEntityGraph(Author.class);

Subgraph<Book> bookGraph = entityGraph.addSubgraph("books");
bookGraph.addAttributeNodes("publisher");

Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.fetchgraph", entityGraph);
Author author = entityManager.find(Author.class, id, properties);

项目 9:如何处理实体图和基本属性

当 Hibernate JPA 出现时,使用实体图只获取实体的一些基本属性(不是全部)需要一个折衷的解决方案,它基于:

  • 启用 Hibernate 字节码增强

  • @Basic(fetch = FetchType.LAZY)标注不应该是实体图一部分的基本属性

主要的缺点在于,这些基本属性是由所有其他查询(例如findById())缓慢获取的,而不仅仅是由使用实体图的查询获取的,而且最有可能的是,您不希望出现这种行为。所以慎用!

遵照 JPA 规范,实体图可以通过两个属性——?? 和 ??——覆盖当前的 ?? 语义。根据所使用的属性,实体图可以是获取图加载图。在获取图形的情况下,attributeNodes中出现的属性被视为FetchType.EAGER。不管默认/显式FetchType如何,其余属性都被视为FetchType.LAZY。在负载图的情况下,attributeNodes中的属性被视为FetchType.EAGER。其余属性根据其指定或默认FetchType进行处理。

也就是说,让我们假设AuthorBook实体包含在一个双向惰性@OneToMany关联中。此外,在Author实体中,让我们定义一个实体图来加载作者和相关书籍的名称。不需要加载作者的年龄和流派,所以实体图中不指定agegenre基本字段:

@Entity
@NamedEntityGraph(
    name = "author-books-graph",
    attributeNodes = {
        @NamedAttributeNode("name"),
        @NamedAttributeNode("books")
    }
)
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author", orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    // getters and setters omitted for brevity
}

让我们用AuthorRepository中的这个实体图。要将两者放在同一个存储库中,可以通过查询构建器机制使用两种方法。它产生几乎相同的名为findByAgeGreaterThanAndGenre()findByGenreAndAgeGreaterThan()的 SQL 语句:

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.FETCH)
    public List<Author> findByAgeGreaterThanAndGenre(int age, String genre);

    @EntityGraph(value = "author-books-graph",
                type = EntityGraph.EntityGraphType.LOAD)
    public List<Author> findByGenreAndAgeGreaterThan(String genre, int age);
}

调用findByAgeGreaterThanAndGenre()会触发下面的 SQL SELECT语句(这是获取图):

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.age > ?
AND author0_.genre = ?

注意,即使agegenre不是获取图的一部分,它们也已经在查询中被获取。让我们通过findByGenreAndAgeGreaterThan()试试负载图:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
AND author0_.age > ?

这次agegenre的出现是正常的。但是这些属性(agegenre)在获取图的情况下也会被加载,即使它们没有通过@NamedAttributeNode被明确指定。

默认情况下,属性用@Basic标注,这依赖于默认的获取策略。默认的获取策略是FetchType.EAGER。基于这一陈述,一个折衷的解决方案包括用@Basic(fetch = FetchType.LAZY)在获取图中标注不应该获取的基本属性,如下所示:

...
@Basic(fetch = FetchType.LAZY)
private String genre;
@Basic(fetch = FetchType.LAZY)
private int age;
...

但是再次执行获取和加载图揭示了完全相同的查询。这意味着 JPA 规范不适用于具有基本(@Basic)属性的 Hibernate。只要没有启用字节码增强,获取图和加载图都会忽略这些设置。在 Maven 中,添加以下插件:

<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <version>${hibernate.version}</version>
    <executions>
        <execution>
            <configuration>
                <failOnError>true</failOnError>
                <enableLazyInitialization>true</enableLazyInitialization>
            </configuration>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
</plugin>

最后,执行获取图将显示预期的SELECT:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.age > ?
AND author0_.genre = ?

执行负载图也将显示预期的SELECT:

SELECT
  author0_.id AS id1_0_0_,
  books1_.id AS id1_1_1_,
  author0_.name AS name4_0_0_,
  books1_.author_id AS author_i4_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.title AS title3_1_1_,
  books1_.author_id AS author_i4_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
AND author0_.age > ?

完整的应用可在 GitHub 14 上获得。

第 10 项:如何通过特定于 Hibernate 的@Where 注释过滤关联

只有在JOIN FETCH WHERE ( 第 39 项)或@NamedEntityGraph ( 第 7 项第 8 项)不适合你的情况下,才依靠@Where的方法。

@Where注释使用起来很简单,并且可以通过在查询中附加一个WHERE子句来过滤提取的关联。

让我们使用双向惰性@OneToMany关联中涉及的AuthorBook实体。目标是延迟获取以下内容:

  • 所有书籍

  • 所有低于 20 美元的书

  • 所有超过 20 美元的书

为了过滤更便宜/更贵的书,Author实体如下依赖于@Where:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author", orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    @OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author", orphanRemoval = true)
    @Where(clause = "price <= 20")
    private List<Book> cheapBooks = new ArrayList<>();

    @OneToMany(cascade = CascadeType.ALL,
              mappedBy = "author", orphanRemoval = true)
    @Where(clause = "price > 20")
    private List<Book> restOfBooks = new ArrayList<>();
    ...
}

此外,让我们编写将触发三个查询的三个服务方法:

@Transactional(readOnly = true)
public void fetchAuthorWithAllBooks() {

    Author author = authorRepository.findById(1L).orElseThrow();
    List<Book> books = author.getBooks();

    System.out.println(books);
}

@Transactional(readOnly = true)
public void fetchAuthorWithCheapBooks() {

    Author author = authorRepository.findById(1L).orElseThrow();
    List<Book> books = author.getCheapBooks();

    System.out.println(books);
}

@Transactional(readOnly = true)
public void fetchAuthorWithRestOfBooks() {

    Author author = authorRepository.findById(1L).orElseThrow();
    List<Book> books = author.getRestOfBooks();

    System.out.println(books);
}

调用fetchAuthorWithCheapBooks()触发下面的 SQL 语句,该语句获取低于 20 美元的书籍:

SELECT
  cheapbooks0_.author_id AS author_i5_1_0_,
  cheapbooks0_.id AS id1_1_0_,
  cheapbooks0_.id AS id1_1_1_,
  cheapbooks0_.author_id AS author_i5_1_1_,
  cheapbooks0_.isbn AS isbn2_1_1_,
  cheapbooks0_.price AS price3_1_1_,
  cheapbooks0_.title AS title4_1_1_
FROM book cheapbooks0_
WHERE (cheapbooks0_.price <= 20)
AND cheapbooks0_.author_id = ?

Hibernate 添加了WHERE子句,指示数据库通过price <= 20过滤书籍。

调用fetchAuthorWithRestOfBooks()会追加WHERE子句,按照price > 20过滤书籍:

SELECT
  restofbook0_.author_id AS author_i5_1_0_,
  restofbook0_.id AS id1_1_0_,
  restofbook0_.id AS id1_1_1_,
  restofbook0_.author_id AS author_i5_1_1_,
  restofbook0_.isbn AS isbn2_1_1_,
  restofbook0_.price AS price3_1_1_,
  restofbook0_.title AS title4_1_1_
FROM book restofbook0_
WHERE (restofbook0_.price > 20)
AND restofbook0_.author_id = ?

完整的应用可在 GitHub 15 上获得。

注意,这些查询以一种懒惰的方式获取书籍。换句话说,这些是在单独的SELECT中获取作者后触发的额外的SELECT查询。只要你不想在同一个SELECT中获取作者和相关书籍,这是没问题的。在这种情况下,应该避免从LAZY切换到EAGER。所以依靠JOIN FETCH WHERE至少从两个方面来说要好很多:

  • 它获取与作者在同一个SELECT中的相关书籍

  • 它允许我们将给定的价格作为查询绑定参数传递

尽管如此,@Where在很多情况下还是有用的。例如,它可以用于软删除实现(项 109 )。

第 11 项:如何通过@MapsId 优化单向/双向@OneToOne

让我们使用一个@OneToOne关联中涉及的AuthorBook实体。图 1-10 中有对应的一一对应的表关系。

img/487471_1_En_1_Fig10_HTML.jpg

图 1-10

一对一的表关系

在关系数据库(RDBMS)中,一对一的关联涉及通过唯一外键“链接”的父端和子端。在 JPA 中,这种关联通过@OneToOne注释进行映射,关联可以是单向的,也可以是双向的。

在这种背景下,为什么@MapsId在单向和双向@OneToOne关联中如此重要?好吧,让我们使用一个常规的映射,从性能的角度突出缺点。因此,我们把重点放在单向的@OneToOne联想上。

常规单向@一对一

Author是一对一关联的父端,而Book是子端。这里列出了Author实体:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    // getters and setters omitted for brevity
}

@OneToOne注释被添加到子端,如下所示:

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String isbn;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    // getters and setters omitted for brevity
}

@JoinColumn注释用于定制外键列的名称。

单向@OneToOne控制相关的外键。换句话说,关系的拥有方控制外键。您从如下服务方法调用setAuthor()(不要在生产中使用orElseThrow();这里只是用来快速的从返回的Optional中打开Author:

@Transactional
public void newBookOfAuthor() {

    Author author = authorRepository.findById(1L).orElseThrow();

    Book book = new Book();
    book.setTitle("A History of Ancient Prague");
    book.setIsbn("001-JN");
    book.setAuthor(author);

    bookRepository.save(book);
}

调用newBookOfAuthor()将在book表中产生以下INSERT语句:

INSERT INTO book (author_id, isbn, title)
  VALUES (?, ?, ?)
Binding:[1, 001-JN, A History of Ancient Prague]

因此,JPA 持久性提供者(Hibernate)已经用author标识符填充了外键列(author_id)的值。

到目前为止一切看起来都很好!然而,当这样一个关联的父端需要获取关联的子节点时,它需要触发一个 JPQL 查询,因为子实体标识符是未知的。查看下面的 JPQL 查询:

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

    @Query("SELECT b FROM Book b WHERE b.author = ?1")
    public Book fetchBookByAuthor(Author author);
}

并且,服务方法如下:

@Transactional(readOnly = true)
public Book fetchBookByAuthor() {
    Author author = authorRepository.findById(1L).orElseThrow();

    return bookRepository.fetchBookByAuthor(author);
}

调用fetchBookByAuthor()将产生以下 SQL 语句:

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i4_1_,
  book0_.isbn AS isbn2_1_,
  book0_.title AS title3_1_
FROM book book0_
WHERE book0_.author_id = ?
Binding:[1] Extracted:[1, 1, 001-JN, A History of Ancient Prague]

如果父端也经常/总是需要子端,那么触发新的查询可能会降低性能。

如果应用使用二级缓存来存储AuthorBook的话,突出显示的性能损失会变得更严重。虽然AuthorBook存储在二级缓存中,但是提取相关的子对象仍然需要通过此处列出的 JPQL 查询进行数据库往返。假设父节点知道子节点的标识符,它可以如下利用二级缓存(不要把你的注意力给orElseThrow();只是为了快速解决返回的Optional):

Author author = authorRepository.findById(1L).orElseThrow();
Book book = bookRepository.findById(author.getId()).orElseThrow();

但是,由于子标识符未知,所以不能使用该代码。

其他(不是更好的)解决方法是依赖查询缓存或@NaturalId

常规双向@一对一

让我们使用双向@OneToOne关联中涉及的AuthorBook实体。换句话说,父端依赖于mappedBy如下(子端保持不变):

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    @OneToOne(mappedBy = "author", cascade = CascadeType.ALL,
             fetch = FetchType.LAZY)
    private Book book;

    // getters and setters omitted for brevity
}

双向@OneToOne的主要缺点可以通过提取父对象Author来观察,如下所示:

Author author = authorRepository.findById(1L).orElseThrow();

即使这是一个LAZY关联,获取Author也会触发下面的SELECT语句:

SELECT
  author0_.id AS id1_0_0_,
  author0_.age AS age2_0_0_,
  author0_.genre AS genre3_0_0_,
  author0_.name AS name4_0_0_
FROM author author0_
WHERE author0_.id = ?

SELECT
  book0_.id AS id1_1_0_,
  book0_.author_id AS author_i4_1_0_,
  book0_.isbn AS isbn2_1_0_,
  book0_.title AS title3_1_0_
FROM book book0_
WHERE book0_.author_id = ?

除了父实体,Hibernate 还提取了子实体。显然,如果应用只需要父节点,那么获取子节点只是浪费资源,这会降低性能。

第二个查询是由父端困境引起的。如果不获取子实体,JPA 持久提供者(Hibernate)就无法知道是否应该将子引用分配给nullObject(具体对象或代理对象)。在这种情况下,通过optional=false元素向@OneToOne添加非空性意识没有帮助。

一个解决方法是依赖字节码增强和父端的@LazyToOne(LazyToOneOption.NO_PROXY)。或者,更好的是,依靠单向的@OneToOne@MapsId

@MapsId 拯救@OneToOne

@MapsId是一个 JPA 2.0 注释,可以应用于@ManyToOne和单向(或双向)@OneToOne关联。通过这个注释,book表的主键也可以是引用author表主键的外键。authorbook表共享主键(子表与父表共享主键),如图 1-11 所示。

img/487471_1_En_1_Fig11_HTML.jpg

图 1-11

@MapsId 和@OneToOne 共享密钥

您将@MapsId添加到子实体,如下所示:

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    private String title;
    private String isbn;

    @MapsId
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    // getters and setters omitted for brevity
}

检查Book实体的标识符。不需要生成它(@GeneratedValue不存在),因为这个标识符正是author关联的标识符。Book标识符由 Hibernate 为您设置。

@JoinColumn注释用于定制主键列的名称。

父实体非常简单,因为不需要双向的@OneToOne(如果这是您最初拥有的)。Author如下:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String genre;
    private int age;

    // getters and setters omitted for brevity
}

现在,您可以通过如下服务方法持久化一个Book(考虑突出显示的注释):

@Transactional
public void newBookOfAuthor() {
    Author author = authorRepository.findById(1L).orElseThrow();

    Book book = new Book();
    book.setTitle("A History of Ancient Prague");
    book.setIsbn("001-JN");

    // this will set the id of the book as the id of the author
    book.setAuthor(author);

    bookRepository.save(book);
}

调用newBookOfAuthor()揭示了下面的INSERT语句(这是调用save()方法的效果):

INSERT INTO book (isbn, title, author_id)
  VALUES (?, ?, ?)
Binding:[001-JN, A History of Ancient Prague, 1]

注意author_id被设置为author标识符。这意味着父表和子表共享同一个主键。

此外,开发者可以通过Author标识符获取Book,如下所示(由于标识符在AuthorBook之间共享,开发者可以依靠author.getId()来指定Book标识符):

@Transactional(readOnly = true)
public Book fetchBookByAuthorId() {
    Author author = authorRepository.findById(1L).orElseThrow();

    return bookRepository.findById(author.getId()).orElseThrow();
}

使用@MapsId有很多好处,如下所示:

  • 如果Book存在于二级缓存中,它将被相应地提取(不需要额外的数据库往返)。这是常规单向@OneToOne的主要缺点。

  • 获取Author也不会自动触发获取Book的不必要的额外查询。这是常规双向@OneToOne的主要缺点。

  • 共享主键减少了内存占用(不需要索引主键和外键)。

完整的代码可以在 GitHub 16 上找到。

第 12 项:如何验证只有一个关联是非空的

考虑一下Review实体。它定义了与BookArticleMagazine的三种@ManyToOne关系:

@Entity
public class Review implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Book book;

    @ManyToOne(fetch = FetchType.LAZY)
    private Article article;

    @ManyToOne(fetch = FetchType.LAZY)
    private Magazine magazine;

    // getters and setters omitted for brevity
}

在这种情况下,评论可以与一本书、一本杂志或一篇文章相关联。通过 Bean 验证 17 可以在应用级别实现这个约束。首先定义一个将在类级别添加到Review实体的注释:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {JustOneOfManyValidator.class})
public @interface JustOneOfMany {

    String message() default "A review can be associated with either
                              a book, a magazine or an article";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

在 Bean 验证文档之后,@JustOneOfMany注释由以下验证提供支持:

public class JustOneOfManyValidator
       implements ConstraintValidator<JustOneOfMany, Review> {

    @Override
    public boolean isValid(Review review, ConstraintValidatorContext ctx) {

       return Stream.of(
              review.getBook(), review.getArticle(), review.getMagazine())
          .filter(Objects::nonNull)
          .count() == 1;
    }
}

最后,只需将类级别的@JustOneOfMany注释添加到Review实体中:

@Entity
@JustOneOfMany
public class Review implements Serializable {
    ...
}

测试时间

数据库已经包含一个Book、一个Article和一个Magazine。下面的服务方法将成功保存一个BookReview:

@Transactional
public void persistReviewOk() {

    Review review = new Review();
    review.setContent("This is a book review ...");
    review.setBook(bookRepository.findById(1L).get());

    reviewRepository.save(review);
}

另一方面,下面的服务方法将不会成功地持久化一个Review。它将无法通过通过@JustOneOfMany指定的验证,因为代码试图将这个审查设置为ArticleMagazine:

@Transactional
public void persistReviewWrong() {

    Review review = new Review();
    review.setContent("This is an article and magazine review ...");
    review.setArticle(articleRepository.findById(1L).get());

    // this will fail validation
    review.setMagazine(magazineRepository.findById(1L).get());

    reviewRepository.save(review);
}

尽管如此,请注意本机查询可以绕过这种应用级验证。如果您知道这种情况是可能的,那么您也必须在数据库级别添加这种验证。在 MySQL 中,这可以通过一个TRIGGER来完成,如下所示:

CREATE TRIGGER Just_One_Of_Many
    BEFORE INSERT ON review
    FOR EACH ROW
    BEGIN
        IF (NEW.article_id IS NOT NULL AND NEW.magazine_id IS NOT NULL)
            OR (NEW.article_id IS NOT NULL AND NEW.book_id IS NOT NULL)
            OR (NEW.book_id IS NOT NULL AND NEW.magazine_id IS NOT NULL) THEN
                SIGNAL SQLSTATE '45000'
                SET MESSAGE_TEXT='A review can be associated with either
                                  a book, a magazine or an article';
        END IF;
END;

完整的应用可在 GitHub 18 上获得。

Footnotes 1

hibernate pringb ootonomandybidi 直肠

  2

hibernate pringb 欧顿云 id 定向

  3

hibernate pringb otjutmann ton

  4

hibernate pringb 欧塔曼妥瑞巴 id

  5

https://vladmihalcea.com/the-best-way-to-map-a-many-to-many-association-with-extra-columns-when-using-jpa-and-hibernate

  6

hibernate pringb ootmanyymybid 定向列表 Vs 集

  7

https://hibernate.atlassian.net/browse/HHH-5855

  8

hibernate pringb otmanytomanyset andorderby

  9

hibernate pringb oostcascadhilder emoval

  10

https://vladmihalcea.com/hibernate-multiplebagfetchexception/

  11

hibernate pringb oostnamedgyr aph

  12

hibernate pringb ootsygrapat tributeepath

  13

Hibernate 弹簧靴命名存根图

  14

hibernate pringb 或 named entity ygr aphbasitrs

  15

hibernate pringb 欧顿滤波关联站

  16

hibernate pringb ootonoonemaps d

  17

https://beanvalidation.org/

  18

hibernate pringb otchousenlyone 协会