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

171 阅读1小时+

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

原文:Spring Boot Persistence Best Practices

协议:CC BY-NC-SA 4.0

二、实体

项目 13:如何在实体中采用流畅的 API 风格

考虑AuthorBook实体,它们涉及到一个双向懒惰@OneToMany关联,如图 2-1 所示。

img/487471_1_En_2_Fig1_HTML.jpg

图 2-1

@OneToMany 表关系

通常,您可以用Book s 创建一个Author,如下所示(例如,一个作者有两本书):

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

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

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

// addBook() is a helper method defined in Author class
author.addBook(book1);
author.addBook(book2);

您还可以用至少两种方式流畅地编写这个代码片段。

流畅风格主要是为了可读性和创造一种代码流畅的感觉。

通过实体设置器的流畅风格

让我们通过实体设置器让员工流畅地工作。通常,实体设置器方法返回void。您可以更改实体设置器以返回this而不是void,如下所示(对于 helper 方法也应该这样做):

@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 Author addBook(Book book) {
        this.books.add(book);
        book.setAuthor(this);
        return this;
    }

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

    public Author setId(Long id) {
        this.id = id;
        return this;
    }

    public Author setName(String name) {
        this.name = name;
        return this;
    }

    public Author setGenre(String genre) {
        this.genre = genre;
        return this;
    }

    public Author setAge(int age) {
        this.age = age;
        return this;
    }

    public Author setBooks(List<Book> books) {
        this.books = books;
        return this;
    }

    // getters omitted for brevity
}

@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;

    public Book setId(Long id) {
        this.id = id;
        return this;
    }

    public Book setTitle(String title) {
        this.title = title;
        return this;
    }

    public Book setIsbn(String isbn) {
        this.isbn = isbn;
        return this;
    }

    public Book setAuthor(Author author) {
        this.author = author;
        return this;
    }

    // getters omitted for brevity
}

设置器返回的是this而不是void,因此它们可以以流畅的方式链接,如下所示:

Author author = new Author()
    .setName("Joana Nimar")
    .setAge(34)
    .setGenre("History")
    .addBook(new Book()
        .setTitle("A History of Ancient Prague")
        .setIsbn("001-JN"))
    .addBook(new Book()
        .setTitle("A People's History")
        .setIsbn("002-JN"));

GitHub 1 上有源代码。

流畅风格通过附加方法

您还可以通过其他方法实现流畅风格的方法,而不是改变实体设置器,如下所示:

@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 Author addBook(Book book) {
        this.books.add(book);
        book.setAuthor(this);
        return this;
    }

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

    public Author id(Long id) {
        this.id = id;
        return this;
    }

    public Author name(String name) {
        this.name = name;
        return this;
    }

    public Author genre(String genre) {
        this.genre = genre;
        return this;
    }

    public Author age(int age) {
        this.age = age;
        return this;
    }

    public Author books(List<Book> books) {
        this.books = books;
        return this;
    }

    // getters and setters omitted for brevity
}

@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;

    public Book id(Long id) {
        this.id = id;
        return this;
    }

    public Book title(String title) {
        this.title = title;
        return this;
    }

    public Book isbn(String isbn) {
        this.isbn = isbn;
        return this;
    }

    public Book author(Author author) {
        this.author = author;
        return this;
    }

    // getters and setters omitted for brevity
}

这一次,这些额外的方法可以用在流畅风格的方法中,如下面的代码片段所示:

Author author = new Author()
    .name("Joana Nimar")
    .age(34)
    .genre("History")
    .addBook(new Book()
        .title("A History of Ancient Prague")
        .isbn("001-JN"))
    .addBook(new Book()
        .title("A People's History")
        .isbn("002-JN"));

GitHub 2 上有源代码。

第 14 项:如何通过特定于 Hibernate 的代理填充子级父级关联

您可以通过 Spring 内置的查询方法findById()getOne()按标识符获取实体。在findById()方法后面,Spring 使用EntityManager#find(),在getOne()方法后面,Spring 使用EntityManager#getReference()

调用findById()从持久化上下文、二级缓存或数据库返回实体(这是尝试查找指定实体的严格顺序)。因此,返回的实体与声明的实体映射的类型相同。

另一方面,调用getOne()将返回特定于 Hibernate 的代理对象。这不是实际的实体类型。当子实体可以通过对其父实体的引用持久化时,特定于 Hibernate 的代理会很有用(@ManyToOne@OneToOne惰性关联)。在这种情况下,从数据库中获取父实体(执行相应的SELECT语句)会降低性能,而且只是一个无意义的动作,因为 Hibernate 可以为未初始化的代理设置底层的外键值。

让我们通过@ManyToOne协会将这一声明付诸实践。这个关联是一个常见的 JPA 关联,它精确地映射到一对多表关系。因此,考虑一下AuthorBook实体包含在一个单向惰性@ManyToOne关联中。在下面的例子中,Author实体代表父端,而Book是子端。该关系中涉及的authorbook表如图 2-2 所示。

img/487471_1_En_2_Fig2_HTML.jpg

图 2-2

一对多表关系

考虑一下,在author表中,有一个 ID 为1的作者。现在,让我们为这个条目创建一个Book

使用 findById()

依赖findById()可能会导致下面的代码(当然生产中不要用orElseThrow();这里,orElseThrow()只是从返回的Optional中提取值的快捷方式:

@Transactional
public void addBookToAuthor() {

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

    Book book = new Book();
    book.setIsbn("001-MJ");
    book.setTitle("The Canterbury Anthology");
    book.setAuthor(author);

    bookRepository.save(book);
}

调用addBookToAuthor()会触发以下 SQL 语句:

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 = ?

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

首先,通过findById()触发一个SELECT查询。这个SELECT从数据库中获取作者。接下来,INSERT语句通过设置外键author_id保存新书。

使用 getOne()

依赖getOne()可能会导致以下代码:

@Transactional
public void addBookToAuthor() {
    Author proxy = authorRepository.getOne(1L);

    Book book = new Book();
    book.setIsbn("001-MJ");
    book.setTitle("The Canterbury Anthology");
    book.setAuthor(proxy);

    bookRepository.save(book);
}

因为 Hibernate 可以设置未初始化代理的底层外键值,所以这段代码会触发一条INSERT语句:

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

显然,这比使用findById()要好。

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

第 15 项:如何在持久层使用 Java 8 可选

此项目的目标是确定在持久层中使用 Java 8 可选 API 的最佳实践。为了在示例中展示这些实践,我们使用双向惰性@OneToMany关联中涉及的众所周知的AuthorBook实体。

编码的黄金法则是,使用事物的最佳方式是为了它们被创建和测试的目的而利用它们。Java 8 Optional也不例外。Java 8 Optional的目的由 Java 的语言架构师 Brian Goetz 明确定义:

  • Optional 旨在为库方法返回类型提供一种有限的机制,在这种情况下需要一种清晰的方式来表示“没有结果”,而使用 null 表示这种情况极有可能导致错误。

记住这句话,让我们把它应用到持久层。

实体中可选

Optional可用于实体。更准确地说,Optional应该用在实体的某些 getter 中(例如,倾向于返回null的 getter)。对于Author实体,Optional可用于namegenre对应的 getters,而对于Book实体,Optional可用于titleisbnauthor,如下:

@Entity
public class Author implements Serializable {
    ...
    public Optional<String> getName() {
        return Optional.ofNullable(name);
    }

    public Optional<String> getGenre() {
        return Optional.ofNullable(genre);
    }
    ...
}

@Entity
public class Book implements Serializable {
    ...
    public Optional<String> getTitle() {
        return Optional.ofNullable(title);
    }

    public Optional<String> getIsbn() {
        return Optional.ofNullable(isbn);
    }

    public Optional<Author> getAuthor() {
        return Optional.ofNullable(author);
    }
    ...
}

请勿将Optional用于:

  • 实体字段(Optional不是Serializable)

  • 构造函数和 setter 参数

  • 返回基本类型和集合的 Getters

  • 特定于主键的 Getters

在存储库中可选

Optional可用于储存库。更准确地说,Optional可以用来包装查询的结果集。Spring 已经自带了返回Optional的内置方法,比如findById()findOne()。下面的代码片段使用了findById()方法:

Optional<Author> author = authorRepository.findById(1L);

此外,您可以编写返回Optional的查询,如以下两个示例所示:

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

    Optional<Author> findByName(String name);
}

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

    Optional<Book> findByTitle(String title);
}

不要假设Optional只与查询构建器机制一起工作。它也适用于 JPQL 和本地查询。以下查询完全没问题:

@Query("SELECT a FROM Author a WHERE a.name=?1")
Optional<Author> fetchByName(String name);

@Query("SELECT a.genre FROM Author a WHERE a.name=?1")
Optional<String> fetchGenreByName(String name);

@Query(value="SELECT a.genre FROM author a WHERE a.name=?1",
       nativeQuery=true)
Optional<String> fetchGenreByNameNative(String name);

GitHub 4 上有源代码。

第 16 项:如何编写不可变的实体

不可变实体必须遵守以下约定:

  • 必须用@Immutable(org.hibernate.annotations.Immutable)标注

  • 它不得包含任何类型的关联(@ElementCollection@OneToOne@OneToMany@ManyToOne@ManyToMany)

  • hibernate.cache.use_reference_entries配置属性必须设置为true

不可变实体作为实体引用存储在二级高速缓存中,而不是作为分解状态。这将防止从实体的拆卸状态重建实体的性能损失(创建一个新的实体实例并用拆卸状态填充它)。

这里,不可变实体将被存储在二级高速缓存中:

@Entity
@Immutable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY, region = "Author")
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

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

    // getters and setters omitted for brevity
}

本书附带的代码提供了一个完整的解决方案,它依赖于二级缓存的 EhCache 实现。

现在,让我们对这个实体应用 CRUD 操作:

  • 创建一个新的Author:下面的方法创建一个新的Author,并把它保存在数据库中。此外,这个Author将通过直写策略存储在二级缓存中(关于二级缓存的更多细节,参见附录 I ):

  • 获取已创建的Author:下一个方法从二级缓存中获取已创建的Author,不命中数据库:

public void newAuthor() {

    Author author = new Author();

    author.setId(1L);
    author.setName("Joana Nimar");
    author.setGenre("History");
    author.setAge(34);

    authorRepository.save(author);
}

  • 更新Author:由于Author是不可变的(它不能被修改),所以这个操作将不起作用。这不会导致任何错误,只会被忽略:
public void fetchAuthor() {
    Author author = authorRepository.findById(1L).orElseThrow();
    System.out.println(author);
}

  • 删除Author:该操作将从二级缓存中提取实体,并将其从两个位置(二级缓存和数据库)删除:
@Transactional
public void updateAuthor() {
    Author author = authorRepository.findById(1L).orElseThrow();
    author.setAge(45);
}

public void deleteAuthor() {
    authorRepository.deleteById(1L);
}

不可变类的实体被自动加载为只读的实体。

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

第 17 项:如何克隆实体

克隆实体不是一项日常任务,但有时这是避免从头开始创建实体的最简单的方法。有许多众所周知的克隆技术,如手动克隆、通过clone()克隆、通过复制构造器克隆、使用克隆库克隆、通过串行化克隆和通过 JSON 克隆。

在实体的情况下,你很少需要使用深度克隆,但是如果这是你所需要的,那么克隆 6 库会非常有用。大多数情况下,您只需要复制属性的子集。在这种情况下,复制构造函数提供了对克隆内容的完全控制。

让我们以双向懒惰的@ManyToMany关联中涉及的AuthorBook实体为例。为了简洁起见,让我们使用图 2-3 (一个有两本书的作者)中的数据快照。

img/487471_1_En_2_Fig3_HTML.jpg

图 2-3

数据快照

克隆父对象并关联书籍

让我们假设 Mark Janel 不是这两本书的唯一作者(我的选集999 选集)。因此,您需要添加合著者。合著者与马克·詹妮尔有着相同的流派和书籍,但却有着不同的年龄和名字。一种解决方案是克隆 Mark Janel 实体,并使用克隆(新实体)来创建合著者。

假设合著者的名字是法雷尔·特里奥普,他是 *54,*你可以期望从图 2-4 中获得数据快照。

img/487471_1_En_2_Fig4_HTML.jpg

图 2-4

克隆父项并关联书籍

为了完成这项任务,您需要关注于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;

    @ManyToMany(...)
    private Set<Book> books = new HashSet<>();

    private Author() {
    }

    public Author(Author author) {
        this.genre = author.getGenre();

        // associate books
        books.addAll(author.getBooks());
    }
    ...
}

Hibernate 内部需要private构造函数。克隆一个Author需要public复制构造器。更准确地说,您只克隆了genre属性。此外,最初的Author实体( Mark Janel )引用的所有Book实体将与新的共同作者实体( Farell Tliop )相关联。

服务方法可以通过初始的Author实体(马克·詹妮尔)创建共同作者实体(法雷尔·特里奥),如下所示:

@Transactional
public void cloneAuthor() {
    Author author = authorRepository.fetchByName("Mark Janel");

    Author authorClone = new Author(author);
    authorClone.setAge(54);
    authorClone.setName("Farell Tliop");

    authorRepository.save(authorClone);
}

被触发的 SQL 语句——除了通过fetchByName()触发的SELECT JOIN FETCH之外——用于提取马克·詹内尔和相关书籍的是预期的INSERT语句:

INSERT INTO author (age, genre, name)
  VALUES (?, ?, ?)
Binding: [54, Anthology, Farell Tliop]

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

INSERT INTO author_book (author_id, book_id)
  VALUES (?, ?)
Binding: [2, 2]

注意,这个例子使用了Set#addAll()方法,而不是传统的addBook()助手。这样做是为了避免由book.getAuthors().add(this)触发的额外的SELECT语句:

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

例如,如果将books.addAll(author.getBooks())替换为:

for (Book book : author.getBooks()) {
    addBook((book));
}

然后,每本书都有一个额外的SELECT。换句话说,合著者和书籍之间的关联双方是同步的。例如,如果在保存合著者之前在 service-method 中运行以下代码片段:

authorClone.getBooks().forEach(
    b -> System.out.println(b.getAuthors()));

你会得到:

[
    Author{id=1, name=Mark Janel, genre=Anthology, age=23},
    Author{id=null, name=Farell Tliop, genre=Anthology, age=54}
]

[
    Author{id=1, name=Mark Janel, genre=Anthology, age=23},
    Author{id=null, name=Farell Tliop, genre=Anthology, age=54}
]

您可以看到作者和合著者 id 是null,因为它们没有保存在数据库中,并且您使用的是IDENTITY生成器。另一方面,如果您运行相同的代码片段,依靠Set#addAll(),您将获得:

[
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
]

[
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
]

这一次,合著者是不可见的,因为您没有在书籍上设置它(您没有同步关联的这一侧)。因为Set#addAll()帮助您避免额外的SELECT语句,并且在克隆一个实体后,您可能会立即将它保存在数据库中,这应该不是一个问题。

克隆父对象和书籍

这一次,假设您想要克隆Author ( 马克·詹妮尔)和相关书籍。因此,你应该期待类似图 2-5 的东西。

img/487471_1_En_2_Fig5_HTML.jpg

图 2-5

克隆父对象和书籍

要克隆Book,您需要在Book实体中添加适当的构造函数,如下所示:

@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;

    private Book() {
    }

    public Book(Book book) {
        this.title = book.getTitle();
        this.isbn = book.getIsbn();
    }
    ...
}

Hibernate 内部需要private构造函数。public复制构造器克隆了Book。这个例子克隆了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;

    @ManyToMany(...)
    private Set<Book> books = new HashSet<>();

    private Author() {
    }

    public Author(Author author) {
        this.genre = author.getGenre();

        // clone books
        for (Book book : author.getBooks()) {
            addBook(new Book(book));
        }
    }

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

服务方法保持不变:

@Transactional
public void cloneAuthor() {
    Author author = authorRepository.fetchByName("Mark Janel");

    Author authorClone = new Author(author);
    authorClone.setAge(54);
    authorClone.setName("Farell Tliop");

    authorRepository.save(authorClone);
}

被触发的 SQL 语句——除了通过fetchByName()触发的SELECT JOIN FETCH之外——用于提取马克·詹内尔和相关书籍的是预期的INSERT语句:

INSERT INTO author (age, genre, name)
  VALUES (?, ?, ?)
Binding: [54, Anthology, Farell Tliop]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding: [001, My Anthology]

INSERT INTO book (isbn, title)
  VALUES (?, ?)
Binding: [002, 999 Anthology]

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

INSERT INTO author_book (author_id, book_id)
  VALUES (?, ?)
Binding: [2, 2]

加入这些案例

通过使用一个boolean参数来重塑Author的复制构造函数,您可以很容易地从 service-method 中决定这两种情况(克隆父节点并关联图书或克隆父节点并关联图书),如下所示:

public Author(Author author, boolean cloneChildren) {
    this.genre = author.getGenre();

    if (!cloneChildren) {
        // associate books
        books.addAll(author.getBooks());
    } else {
        // clone each book
        for (Book book : author.getBooks()) {
            addBook(new Book(book));
        }
    }
}

完整的应用可以在 GitHub 7 中找到。

第 18 项:为什么以及如何激活脏追踪

Dirty Checking

是一种 Hibernate 机制,专门用于在刷新时检测托管实体,这些实体自从被加载到当前持久性上下文中以来已经被修改过。然后,它代表应用(数据访问层)触发相应的 SQL UPDATE语句。请注意,Hibernate 会扫描所有受管实体,即使受管实体中只有一个属性发生了变化。

在 Hibernate 5 之前,脏检查机制依赖于 Java 反射 API 来检查每个托管实体的每个属性。从性能的角度来看,只要实体的数量相对较少,这种方法就是“无害的”。对于大量受管实体,这种方法可能会导致性能损失。

从 Hibernate 5 开始,脏检查机制依赖于脏跟踪机制,这是一个实体跟踪其自身属性变化的能力。脏跟踪机制会带来更好的性能,其好处是显而易见的,尤其是当实体的数量非常大时。为了工作,脏跟踪机制需要将 Hibernate 字节码增强进程添加到应用中。此外,开发人员必须通过特定的标志配置启用脏跟踪机制:

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

一般来说,字节码增强是为了某些目的而检测 Java 类的字节码的过程。Hibernate 字节码增强是一个通常发生在构建时的过程;因此,它不会影响应用的运行时(没有运行时性能损失,但当然在构建期间会有开销)。但是,它可以设置为在运行时或部署时发生。

您可以通过添加相应的 Maven 或 Gradle 插件(Ant 也受支持)来为您的应用添加字节码增强。

一旦添加了字节码增强插件,所有实体类的字节码都会被插装。这个过程被称为插装,它包括向代码添加一组服务于所选配置所需的指令(例如,您需要插装实体的代码以进行脏跟踪;通过这种手段,实体能够跟踪它的哪些属性已经改变)。在刷新时,Hibernate 将要求每个实体报告任何变化,而不是依赖状态差异计算。

您可以通过enableDirtyTracking配置启用脏跟踪。

尽管如此,仍然推荐使用瘦持久性上下文。 水合状态 (实体快照)仍然保存在持久化上下文中。

要检查脏跟踪是否被激活,只需反编译实体类的源代码并搜索以下代码:

@Transient
private transient DirtyTracker $$_hibernate_tracker;

$$_hibernate_tracker用于登记实体修改。在刷新期间,Hibernate 调用一个名为$$_hibernate_hasDirtyAttributes()的方法。该方法将脏属性作为一个String[]返回。

或者,只需检查日志中的消息,如下所示:

INFO: Enhancing [com.bookstore.entity.Author] as Entity
Successfully enhanced class [D:\...\com\bookstore\entity\Author.class]

Hibernate 字节码增强服务于三种主要机制(对于每种机制,Hibernate 将在字节码中加入适当的插装指令):

  • 脏污痕迹(包含在此项中):enableDirtyTracking

  • 属性惰性初始化(第 23 项 ): enableLazyInitialization

  • 关联管理(在双向关联的情况下自动两侧同步):enableAssociationManagement

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

第 19 项:如何将布尔值映射为是/否

考虑一个遗留数据库,它有一个表author,该表使用以下数据定义语言(DDL):

CREATE TABLE author (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  age int(11) NOT NULL,
  best_selling varchar(3) NOT NULL,
  genre varchar(255) DEFAULT NULL,
  name varchar(255) DEFAULT NULL,
  PRIMARY KEY (id)
);

注意best_selling栏。该列存储两个可能的值,,表示作者是否是畅销书作者。此外,让我们假设这个模式不能被修改(例如,它是遗留的,您不能修改它),并且best_selling列应该被映射到一个Boolean值。

显然,将相应的实体属性声明为Boolean是必要的,但并不充分:

@Entity
public class Author implements Serializable {

    ...
    @NotNull
    private Boolean bestSelling;
    ...

    public Boolean isBestSelling() {
        return bestSelling;
    }

    public void setBestSelling(Boolean bestSelling) {
        this.bestSelling = bestSelling;
    }
}

此时,Hibernate 将尝试映射这个Boolean,如下表所示:

|

Java 类型

|

**<->-**冬眠类型

|

JDBC 式

| | --- | --- | --- | | boolean/Boolean | BooleanType | BIT | | boolean/Boolean | NumericBooleanType | INTEGER (e.g, 0 or 1) | | boolean/Boolean | YesNoType | CHAR (e.g., N/n or Y/y) | | boolean/Boolean | TrueFalseType | CHAR (e.g., F/f or T/t) |

因此,这些映射都不匹配VARCHAR(3)。一个优雅的解决方案是编写一个定制的转换器,Hibernate 将把它应用于所有的 CRUD 操作。这可以通过实现AttributeConverter接口并覆盖它的两个方法来实现:

@Converter(autoApply = true)
public class BooleanConverter
       implements AttributeConverter<Boolean, String> {

    @Override
    public String convertToDatabaseColumn(Boolean attr) {

       return attr == null ? "No" : "Yes";
    }

    @Override
    public Boolean convertToEntityAttribute(String dbData) {

       return !"No".equals(dbData);
    }
}

convertToDatabaseColumn()Boolean转换为String,而convertToEntityAttribute()String转换为Boolean

这个转换器用@Converter(autoApply = true)标注,这意味着这个转换器将用于被转换类型的所有属性(Boolean)。要指定属性,只需删除autoApply或将其设置为false,并在属性级别添加@Converter,如下所示:

@Convert(converter = BooleanConverter.class)

private Boolean bestSelling;

请注意,AttributeConverter不能应用于用@Enumerated注释的属性。

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

第 20 项:从聚合根发布域事件的最佳方式

由 Spring 存储库管理的实体被称为聚合根。在域驱动设计(DDD)中,聚合根可以发布事件或域事件。从 Spring Data Ingalls 发行版开始,通过聚合根(实体)发布这样的事件变得容易多了。

Spring Data 附带了一个@DomainEvents注释,可以用在聚合根的方法上,使发布尽可能简单。用@DomainEvents注释的方法被 Spring 数据识别,并且每当使用适当的存储库保存实体时,该方法被自动调用。此外,除了@DomainEvents注释之外,Spring Data 还提供了@AfterDomainEventsPublication注释来指示在发布后应该自动调用来清除事件的方法。在代码中,这通常如下所示:

class MyAggregateRoot {

    @DomainEvents
    Collection<Object> domainEvents() {
        // return events you want to get published here
    }

    @AfterDomainEventsPublication
    void callbackMethod() {
        // potentially clean up domain events list
    }
}

但是 Spring Data Commons 附带了一个方便的模板基类(AbstractAggregateRoot),它帮助注册域事件并使用@DomainEvents@AfterDomainEventsPublication隐含的发布机制。通过调用AbstractAggregateRoot#registerEvent()方法来注册事件。如果您调用 Spring 数据仓库的 save 方法之一(例如save())并在发布后清除它,注册的域事件就会被发布。

让我们看一个依赖于AbstractAggregateRoot及其registerEvent()方法的示例应用。有两个实体——BookBookReview——参与了一个双向懒惰@OneToMany协会。新的书评以CHECK状态保存到数据库,并发布CheckReviewEvent。该事件负责检查复习语法、内容等。,并将审查状态从CHECK切换到ACCEPTREJECT。然后,它在数据库中传播新的状态。所以,这个事件是在保存处于CHECK状态的书评之前注册的,并且在您调用BookReviewRepository.save()方法之后自动发布。发布后,事件被清除。

让我们从聚合器根开始,BookReview:

@Entity
public class BookReview extends AbstractAggregateRoot<BookReview>
            implements Serializable {

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

    private String content;
    private String email;

    @Enumerated(EnumType.STRING)
    private ReviewStatus status;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "book_id")
    private Book book;

    public void registerReviewEvent() {
        registerEvent(new CheckReviewEvent(this));
    }

    // getters, setters, etc omitted for brevity
}

BookReview扩展AbstractAggregateRoot并公开registerReviewEvent()方法,通过AbstractAggregateRoot#registerEvent()注册域事件。在保存书评之前,调用registerReviewEvent()方法来注册事件(CheckReviewEvent):

@Service
public class BookstoreService {

    private final static String RESPONSE
        = "We will check your review and get back to you with an email ASAP :)";

    private final BookRepository bookRepository;
    private final BookReviewRepository bookReviewRepository;
    ...

    @Transactional
    public String postReview(BookReview bookReview) {

        Book book = bookRepository.getOne(1L);
        bookReview.setBook(book);

        bookReview.registerReviewEvent();

        bookReviewRepository.save(bookReview);

        return RESPONSE;
    }
}

在调用了save()方法并且提交了事务之后,事件被发布。这里列出了CheckReviewEvent(它传递了bookReview实例,但是您也可以通过编写适当的构造函数只传递所需的属性):

public class CheckReviewEvent {

    private final BookReview bookReview;

    public CheckReviewEvent(BookReview bookReview) {
        this.bookReview = bookReview;
    }

    public BookReview getBookReview() {
        return bookReview;
    }
}

最后,您需要事件处理程序,其实现如下:

@Service
public class CheckReviewEventHandler {

    public final BookReviewRepository bookReviewRepository;
    ...

    @TransactionalEventListener
    public void handleCheckReviewEvent(CheckReviewEvent event) {

        BookReview bookReview = event.getBookReview();

        logger.info(() -> "Starting checking of review: "
            + bookReview.getId());

        try {
            // simulate a check out of review grammar, content, acceptance
            // policies, reviewer email, etc via artificial delay of 40s for
            // demonstration purposes
            String content = bookReview.getContent(); // check content
            String email = bookReview.getEmail(); // validate email

            Thread.sleep(40000);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            // log exception
        }

        if (new Random().nextBoolean()) {
            bookReview.setStatus(ReviewStatus.ACCEPT);
            logger.info(() -> "Book review " + bookReview.getId()
                + " was accepted ...");
        } else {
            bookReview.setStatus(ReviewStatus.REJECT);
            logger.info(() -> "Book review " + bookReview.getId()
                + " was rejected ...");
        }

        bookReviewRepository.save(bookReview);

        logger.info(() -> "Checking review " + bookReview.getId() + " done!");
    }
}

我们模拟检查评论语法、内容、接受政策、评论者电子邮件等。出于演示目的,通过 40 秒(Thread.sleep(40000);)的人工延迟。审阅检查完成后,审阅状态会在数据库中更新。

同步执行

事件处理程序用@TransactionalEventListener标注。事件处理程序可以通过phase元素显式绑定到发布事件的事务阶段。通常,在事务成功完成后处理事件(TransactionPhase.AFTER_COMMIT)。AFTER_COMMIT@TransactionalEventListener的默认设置,可以进一步定制为BEFORE_COMMITAFTER_COMPLETION(事务已完成,无论成功与否)或AFTER_ROLLBACK(事务已回滚)。AFTER_COMMITAFTER_ROLLBACKAFTER_COMPLETION的专门化。

如果没有事务正在运行,用@TransactionalEventListener标注的方法将不会被执行,除非有一个名为fallbackExecution的参数被设置为true

由于我们依赖于AFTER_COMMIT并且没有为handleCheckReviewEvent()指定明确的事务上下文,我们可能期望审查检查(通过Thread.sleep()模拟)将在事务之外运行。此外,我们期待一个由save()方法(bookReviewRepository.save(bookReview);)调用引起的UPDATE。这个UPDATE应该被包装在一个新的事务中。但是如果您分析应用日志,您会发现这与现实相差甚远(这只是输出的相关部分):

Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

Opened new EntityManager [SessionImpl(719882002<open>)] for JPA transaction

begin

insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)

Committing JPA transaction on EntityManager [SessionImpl(719882002<open>)]

committing

// The application flow entered in handleCheckReviewEvent()

Starting checking of review: 1

HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)

Found thread-bound EntityManager [SessionImpl(719882002<open>)] for JPA transaction

Participating in existing transaction

Checking review 1 done!

Closing JPA EntityManager [SessionImpl(719882002<open>)] after transaction

这里需要注意几件事。首先,事务在调用postReview()时开始,并在运行handleCheckReviewEvent()事件处理程序的代码之前提交。这是正常的,因为您指示 Spring 在事务提交后执行handleCheckReviewEvent()(AFTER_COMMIT)。但是提交事务并不意味着事务性资源已经被释放。事务性资源仍然是可访问的。正如您所看到的,连接池中没有返回连接(HikariCP 报告一个活动连接,active=1)并且关联的持久性上下文仍然是打开的。例如,触发一个bookReviewRepository.findById ( book_reivew_id )将从当前持久上下文中获取BookReview

第二,没有执行UPDATE语句!书评状态未传播到数据库。发生这种情况是因为事务已经提交。此时,数据访问代码仍将参与原始事务,但不会有提交(不会有写操作传播到数据库)。这正是这段代码bookReviewRepository.save(bookReview);将要发生的事情。

你很容易得出结论,我们正处于非常不愉快的境地。有一个长时间运行的事务(由于通过Thread.sleep()模拟的长流程),最终没有更新书评状态。你可能认为切换到AFTER_COMPLETION(或AFTER_ROLLBACK)会在执行handleCheckReviewEvent()之前返回连接池中的连接,在handleCheckReviewEvent()级别添加@Transactional会触发预期的UPDATE语句。但是下面这些都无济于事。结果将完全相同:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

@Transactional
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

@Transactional
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

要解决这种情况,您必须通过Propagation.REQUIRES_NEW明确要求为handleCheckReviewEvent()创建一个新事务,如下所示:

@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

将更改(写操作)传播到事件处理程序中的数据库(用@TransactionalEventListener标注的方法)需要一个显式的新事务(Propagation.REQUIRES_NEW)。但是一定要阅读下面的讨论,因为从性能的角度来看,这不是没有代价的。

让我们再次检查应用日志:

Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

Opened new EntityManager [SessionImpl(514524928<open>)] for JPA transaction

begin

insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)

Committing JPA transaction on EntityManager [SessionImpl(514524928<open>)]

committing

// The application flow entered in handleCheckReviewEvent()

Suspending current transaction, creating new transaction with name [com.bookstore.event.CheckReviewEventHandler.handleCheckReviewEvent]

Opened new EntityManager [SessionImpl(1879180026<open>)] for JPA transaction

begin

HikariPool-1 - Pool stats (total=10, active=2, idle=8, waiting=0)

Found thread-bound EntityManager [SessionImpl(1879180026<open>)] for JPA transaction

Participating in existing transaction

select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?

Committing JPA transaction on EntityManager [SessionImpl(1879180026<open>)]

committing

update book_review set book_id=?, content=?, email=?, status=? where id=?

Closing JPA EntityManager [SessionImpl(1879180026<open>)] after transaction

Resuming suspended transaction after completion of inner transaction

Closing JPA EntityManager [SessionImpl(514524928<open>)] after transaction

这一次,事务在您调用postReview()时开始,并在应用流到达handleCheckReviewEvent()时暂停。新的事务和新的持久上下文被创建并被进一步使用。触发预期的UPDATE,更新数据库中的书评状态。**在此期间,两个数据库连接是活动的(一个用于挂起的事务,一个用于当前事务)。**该事务提交,附加的数据库连接返回到连接池。此外,暂停的事务被恢复并关闭。最后,调用postReview()时打开的连接被返回到连接池中。显然,这里唯一的好处是触发了UPDATE,但是性能损失很大。这使两个数据库连接长时间保持活动状态。所以,两个长时间运行的事务!要解决这种情况,您可以切换到BEFORE_COMMIT并移除@Transactional:

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

这一次,事务在您调用postReview()时开始,并在运行事件处理程序(handleCheckReviewEvent())结束时提交。所以,书评状态的UPDATE就是在这个事务上下文中触发的。现在,您有了一个长时间运行的事务,并且针对数据库执行了UPDATE。数据库连接在您调用postReview()时打开,在执行handleCheckReviewEvent()结束时关闭。除了这个长时间运行的事务所代表的性能损失之外,您还必须记住,使用BEFORE_COMMIT并不总是适应这种场景。如果您确实需要在继续之前提交事务,这不是一个选项。

或者,您仍然可以依赖于AFTER_COMMIT并延迟通过Propagation.REQUIRES_NEW请求的事务的连接获取。这可以按照第 60 项中的方法完成。所以,在application.properties中,你需要禁用auto-commit:

spring.datasource.hikari.auto-commit=false
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true

@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

让我们来看看应用日志:

// The application flow entered in handleCheckReviewEvent()

Suspending current transaction, creating new transaction with name [com.bookstore.event.CheckReviewEventHandler.handleCheckReviewEvent]

Opened new EntityManager [SessionImpl(1879180026<open>)] for JPA transaction

begin

HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)

Found thread-bound EntityManager [SessionImpl(1879180026<open>)] for JPA transaction

Participating in existing transaction

select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?

Committing JPA transaction on EntityManager [SessionImpl(1879180026<open>)]

committing

update book_review set book_id=?, content=?, email=?, status=? where id=?

Closing JPA EntityManager [SessionImpl(1879180026<open>)] after transaction

Resuming suspended transaction after completion of inner transaction

Closing JPA EntityManager [SessionImpl(514524928<open>)] after transaction

这一次,通过Propagation.REQUIRES_NEW要求的事务被延迟,直到您调用bookReviewRepository.save(bookReview);。这意味着检查书评的漫长过程将打开一个数据库连接,而不是两个。这样好一点了,但还是不能接受。

异步执行

到目前为止,我们还不能说可以忽略相关的性能损失。这意味着我们需要努力进一步优化这些代码。由于图书审核检查过程非常耗时,因此在此过程结束之前,没有必要阻止审核者。正如您在postReview()方法中看到的,在保存书评和注册事件之后,我们返回一个字符串响应作为,We will check your review and get back to you with an email ASAP :)。该实现依赖于同步执行,因此您需要在事件处理程序完成执行后发送这个字符串响应。显然,由于在图书评论检查过程中,评论者被阻止,所以响应较晚。

最好是在事件处理程序开始执行之前立即返回字符串响应,而带有决定的电子邮件可以稍后发送。默认情况下,事件处理程序在调用方线程中执行。因此,是时候让异步执行为事件处理程序分配不同的线程了。在 Spring Boot,您可以通过@EnableAsync启用异步功能。接下来,用@Async注释事件处理程序:

@Async
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleCheckReviewEvent(CheckReviewEvent event) {
    ...
}

是时候再次查看应用日志了:

Creating new transaction with name [...BookstoreService.postReview]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

Opened new EntityManager [SessionImpl(1691206416<open>)] for JPA transaction

begin

insert into book_review (book_id, content, email, status) values (?, ?, ?, ?)

Committing JPA transaction on EntityManager [SessionImpl(1691206416<open>)]
...
Closing JPA EntityManager [SessionImpl(1691206416<open>)] after transaction

Creating new transaction with name [...CheckReviewEventHandler.handleCheckReviewEvent]: PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT

Opened new EntityManager [SessionImpl(1272552918<open>)] for JPA transaction

// since the execution is asynchronous the exact moment in time when the
// string response is sent may slightly vary
Response: We will check your review and get back to you with an email ASAP :)

begin

Starting checking of review: 1

HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)

Found thread-bound EntityManager [SessionImpl(1272552918<open>)] for JPA transaction

Participating in existing transaction

select bookreview0_.id as id1_1_0_, ... where bookreview0_.id=?

Checking review 1 done!

Committing JPA transaction on EntityManager [SessionImpl(1272552918<open>)]
...

这一次,应用日志显示您已经消除了长时间运行的事务。当postReview()调用被提交和关闭时,事务开始(附加的数据库连接在连接池中返回), string-response 被立即发送给检查者。事件处理程序的执行是异步的,需要一个新的线程和一个新的事务。数据库连接的获取被延迟到真正需要的时候(这时应该更新书评状态)。因此,图书检查不会免费保持任何数据库连接活动/繁忙。

一般来说,大多数应用依赖连接池来重用物理数据库连接,而一个数据库服务器只能服务有限数量的这种连接。这意味着执行长时间运行的事务将使连接长时间处于忙碌状态,这将影响可伸缩性。这不符合 MVCC(多版本并发控制)。为了有一个愉快的连接池和数据库服务器,最好有短的数据库事务。在域事件的上下文中,您至少应该注意以下几点,以避免重大的性能损失。

异步执行期间:

  • 如果您需要执行任何非常适合异步执行的任务,请使用带有AFTER_COMPLETION(或其专门化)的异步事件处理程序。

  • 如果这些任务不涉及数据库操作(读/写),那么不要在事件处理程序方法级别使用@Transactional(不要启动新的事务)。

  • 如果这些任务涉及数据库读和/或写操作,那么使用Propagation.REQUIRES_NEW并将数据库连接获取延迟到需要时(在数据库连接打开后,避免耗时的任务)。

  • 如果这些任务只涉及数据库读取操作,那么用@Transactional(readOnly=true, Propagation.REQUIRES_NEW)注释事件处理程序方法。

  • 如果这些任务涉及数据库写操作,那么用@Transactional(Propagation.REQUIRES_NEW)注释事件处理程序方法。

  • 避免在BEFORE_COMMIT阶段执行异步任务,因为您无法保证这些任务会在生产者的事务提交之前完成。

  • 根据您的场景,您可能需要拦截事件处理程序线程的完成。

同步执行时:

  • 考虑异步执行(包括它的特定缺点)。

  • 只有当事件处理程序不耗时并且需要数据库写操作时,才使用BEFORE_COMMIT(当然,如果在提交之前执行事件处理程序代码适合您的场景)。显然,您仍然可以读取当前的持久性上下文(它是打开的)并触发只读数据库操作。

  • 仅当事件处理程序不耗时且不需要数据库写操作时,才使用AFTER_COMPLETION(或其专门化)(尽量避免在同步执行中使用Propagation.REQUIRES_NEW)。尽管如此,您仍然可以读取当前的持久性上下文(它是打开的)并触发只读数据库操作。

  • 在使用BEFORE_COMMIT的情况下,在事件处理程序中执行的数据库操作的失败将回滚整个事务(取决于您的场景,这可能是好的,也可能是不好的)。

Spring 域事件对于简化事件基础设施非常有用,但是要注意以下几点:

  • 域事件只适用于 Spring 数据仓库。

  • 只有当我们显式调用一个保存方法(例如save())时,域事件才会按预期发布。

  • 如果发布事件时发生异常,则不会通知侦听器(事件处理程序)。因此,事件将会丢失。

在应用中使用域事件之前,建议评估一下使用 JPA 回调(第 104 条)、观察者设计模式、特定于 Hibernate 的@Formula ( 第 77 条)或者其他方法是否也能很好地工作。

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

Footnotes 1

hibernate pringb 欧氟辛烷磺酸酯

  2

hibernate pringb otfluentpiaddi 国家方法

  3

hibernate pringb otpopulangchi ldviaproxy

  4

hibernate pringb 欧塔可选

  5

hibernate pringb 不变 ty

  6

https://github.com/kostaskougios/cloning

  7

hibernate pringb oostcloneentity

  8

hibernate pringb bootenabledirtytr packing

  9

hibernate pringb oomapboolean 否

  10

hibernate pringb 欧特域事件

 

三、抓取

第 21 项:如何使用直接抓取

当实体的标识符是已知的并且它的惰性关联不会在当前持久化上下文中导航时,直接获取或通过 ID 获取是获取实体的优选方式。

默认情况下,直接取数会根据默认或指定的FetchType加载实体。重要的是要记住,默认情况下,JPA @OneToMany@ManyToMany关联被认为是LAZY,而@OneToOne@ManyToOne关联被认为是EAGER

因此,通过 ID 获取具有EAGER关联的实体将会在持久性上下文中加载该关联,即使不需要,这会导致性能损失。另一方面,获取一个具有LAZY关联的实体并在当前持久化上下文中访问这个关联也会导致加载它的额外查询——也会导致性能损失。

最好的方法是保留所有的关联LAZY,依靠手动抓取策略(参见第 39 项第 41 项第 43 项)来加载这些关联。只有当您不打算访问当前持久性上下文中的LAZY关联时,才依赖直接获取。

现在,让我们看看几种通过 ID 获取实体的方法。考虑下面的Author实体:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

下面三个例子的目的是使用直接抓取来加载 ID 为1的实体。

通过 Spring 数据直接获取

您可以通过内置的findById()方法直接获取 Spring 数据。该方法获取 ID 作为参数,并返回一个包装相应实体的Optional。在代码中,findById()的用法如下:

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {}

Optional<Author> author = authorRepository.findById(1L);

加载这个Author的 SQL 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 = ?

在幕后,findById()使用了EntityManager.find()方法。

通过 EntityManager 获取

可以通过@PersistenceContext注入EntityManager。有了EntityManager,剩下的就是调用find()方法了。该方法遵循 Spring 数据样式,并返回一个Optional:

@PersistenceContext
private EntityManager entityManager;

@Override
public Optional<T> find(Class<T> clazz, ID id) {
    if (id == null) {
        throw new IllegalArgumentException("ID cannot be null");
    }

    return Optional.ofNullable(entityManager.find(clazz, id));
}

加载这个Author的 SQL SELECT语句与findById()相同:

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 = ?

通过特定于 Hibernate 的会话获取

要使用 Hibernate 特有的Session.get()方法通过 ID 获取数据,您需要从EntityManager中解开Session。以下方法执行此展开并返回一个Optional:

@PersistenceContext
private EntityManager entityManager;

@Override
public Optional<T> findViaSession(Class<T> clazz, ID id) {
    if (id == null) {
        throw new IllegalArgumentException("ID cannot be null");
    }

    Session session = entityManager.unwrap(Session.class);

    return Optional.ofNullable(session.get(clazz, id));
}

加载此Author的 SQL SELECT语句与findById()EntityManager的情况相同:

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 = ?

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

JPA 持久性提供者(Hibernate)通过findById()find()get()获取具有给定 ID 的实体,按以下顺序搜索:

  • 当前的持久性上下文(如果没有找到,转到下一步)

  • 二级缓存(如果没有找到,转到下一步)

  • 数据库

    搜索的顺序是严格的。

直接读取和会话级可重复读取

这一节详细介绍了第一个要点(在当前持久性上下文中搜索)。为什么 Hibernate 首先检查持久化上下文来查找具有给定 ID 的实体?答案是 Hibernate 保证了会话级的可重复读取。这意味着第一次获取的实体被缓存在持久性上下文中(一级缓存)。同一实体的后续获取(通过直接获取或显式实体查询(JPQL/HQL))是从持久性上下文中完成的。换句话说,会话级可重复读取防止在并发写入情况下丢失更新

请看下面的例子,它将这三种直接获取技术归入一个事务性服务方法:

@Transactional(readOnly=true)
public void directFetching() {
    // direct fetching via Spring Data
    Optional<Author> resultSD = authorRepository.findById(1L);
    System.out.println("Direct fetching via Spring Data: "
        + resultSD.get());

    // direct fetching via EntityManager
    Optional<Author> resultEM = dao.find(Author.class, 1L);
    System.out.println("Direct fetching via EntityManager: "
        + resultEM.get());

    // direct fetching via Session
    Optional<Author> resultHS = dao.findViaSession(Author.class, 1L);
    System.out.println("Direct fetching via Session: "
        + resultHS.get());
}

将执行多少条SELECT语句?如果你回答了一个,你就答对了!有一单SELECTauthorRepository.findById(1L)呼叫引起。返回的作者缓存在持久性上下文中。随后的调用——dao.find(Author.class, 1L)dao.findViaSession(Author.class, 1L)——从持久性上下文中获取同一个作者实例,而不会触及底层数据库。

现在,让我们假设我们使用显式 JPQL 查询,如下例所示。首先,我们编写显式的 JPQL,通过 ID 获取作者(我们使用Optional只是为了保持趋势,但它与本主题无关):

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

    @Query("SELECT a FROM Author a WHERE a.id = ?1")
    public Optional<Author> fetchById(long id);
}

接下来,让我们看看下面的服务方法:

@Transactional(readOnly=true)
public void directFetching() {
    // direct fetching via Spring Data
    Optional<Author> resultSD = authorRepository.findById(1L);
    System.out.println("Direct fetching via Spring Data: "
        + resultSD.get());

    // direct fetching via EntityManager
    Optional<Author> resultJPQL = authorRepository.fetchById(1L);
    System.out.println("Explicit JPQL: "
        + resultJPQL.get());
}

将执行多少条SELECT语句?如果你回答了两个,你就对了:

-- triggered by authorRepository.findById(1L)
-- the returned author is loaded in the Persistence Context
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 = ?

-- identical SELECT triggered by authorRepository.fetchById(1L)
-- the returned data snapshot is ignored and
-- the returned author is from Persistence Context
SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
WHERE author0_.id = ?

当持久性上下文为空时,第一个SELECTauthorRepository.findById(1L)调用引起。第二个SELECT命中数据库,因为除非我们使用二级缓存,否则任何显式查询都将针对数据库执行。因此,我们的显式SELECT并不是这个规则的例外。调用authorRepository.fetchById(1L)返回的作者是来自当前加载的数据库快照,还是来自我们调用authorRepository.findById(1L)时加载的持久化上下文?由于持久性上下文保证了会话级的可重复读取,Hibernate 忽略了通过 JPQL 加载的数据库快照,并返回持久性上下文中已经存在的作者。

从性能角度来看,建议使用findById()find()get()而不是显式的 JPQL/SQL 来按 ID 获取实体。这样,如果实体存在于当前的持久化上下文中,就不会触发针对数据库的SELECT,也不会忽略任何数据快照。

虽然乍一看,这种行为可能不是那么明显,但是我们可以通过一个简单的测试来揭示它,这个测试使用了两个通过 Spring TransactionTemplate API 形成的并发事务。考虑以下作者:

INSERT INTO author (age, name, genre, id)
  VALUES (23, "Mark Janel", "Anthology", 1);

和以下服务方法:

private final AuthorRepository authorRepository;
private final TransactionTemplate template;
...
public void process() {

    template.setPropagationBehavior(
        TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    template.setIsolationLevel(Isolation.READ_COMMITTED.value());

    // Transaction A
    template.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(
                              TransactionStatus status) {

            Author authorA1 = authorRepository.findById(1L).orElseThrow();
            System.out.println("Author A1: " + authorA1.getName() + "\n");

            // Transaction B
            template.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(
                                      TransactionStatus status) {

                    Author authorB = authorRepository
                        .findById(1L).orElseThrow();
                    authorB.setName("Alicia Tom");

                    System.out.println("Author B: "
                        + authorB.getName() + "\n");
                }
            });

            // Direct fetching via findById(), find() and get()
            // doesn't trigger a SELECT
            // It loads the author directly from Persistence Context
            Author authorA2 = authorRepository.findById(1L).orElseThrow();
            System.out.println("\nAuthor A2: " + authorA2.getName() + "\n");

            // JPQL entity queries take advantage of
            // session-level repeatable reads
            // The data snapshot returned by the triggered SELECT is ignored
            Author authorViaJpql = authorRepository.fetchByIdJpql(1L);
            System.out.println("Author via JPQL: "
                + authorViaJpql.getName() + "\n");

            // SQL entity queries take advantage of
            // session-level repeatable reads
            // The data snapshot returned by the triggered SELECT is ignored
            Author authorViaSql = authorRepository.fetchByIdSql(1L);
            System.out.println("Author via SQL: "
                + authorViaSql.getName() + "\n");

            // JPQL query projections always load the latest database state
            String nameViaJpql = authorRepository.fetchNameByIdJpql(1L);
            System.out.println("Author name via JPQL: " + nameViaJpql + "\n");

            // SQL query projections always load the latest database state
            String nameViaSql = authorRepository.fetchNameByIdSql(1L);
            System.out.println("Author name via SQL: " + nameViaSql + "\n");
        }
    });
}

有很多代码,但非常简单。首先,我们针对 MySQL 运行这段代码,MySQL 依赖REPEATABLE_READ作为默认隔离级别(关于 Spring 事务隔离级别的更多细节可以在附录 F 中找到)。我们需要切换到READ_COMMITTED隔离级别,以便强调 Hibernate 会话级可重复读取是如何在不交错REPEATABLE_READ隔离级别的情况下工作的。我们还通过设置PROPAGATION_REQUIRES_NEW来确保第二个事务(事务 B)不参与到事务 A 的上下文中(关于 Spring 事务传播的更多细节可以在附录 G 中找到)。

此外,我们启动事务 A(和持久性上下文 A)。在这个事务上下文中,我们调用findById()来获取 ID 为1的作者。因此,这个作者通过适当的SELECT查询被加载到持久化上下文 A 中。

接下来,我们让事务 A 保持原样,并启动事务 B(和持久性上下文 B)。在事务 B 的上下文中,我们通过适当的SELECT加载作者的 ID1,并执行名称更新(马克·詹内尔变成了艾丽西娅·汤姆)。相应的UPDATE在刷新时针对数据库执行,就在事务 B 提交之前。所以现在,在底层数据库中,ID 为1的作者的名字是 Alicia Tom

现在,我们回到事务 A(和持久性上下文 A)并触发一系列查询,如下所示:

  • 首先,我们调用findById()来获取 ID 为1的作者。作者直接从持久化上下文 A 返回(没有任何SELECT,名字是马克·詹妮尔。因此,会话级可重复读取按预期工作。

  • 其次,我们执行以下显式 JPQL 查询(fetchByIdJpql()):

@Query("SELECT a FROM Author a WHERE a.id = ?1")
public Author fetchByIdJpql(long id);

被触发的SELECT返回的数据快照被忽略,返回的作者是来自持久上下文 A 的作者(马克·詹内尔)。同样,会话级可重复读取按预期工作。

  • 接下来,我们执行以下显式本机 SQL 查询(fetchByIdSql()):
@Query(value = "SELECT * FROM author WHERE id = ?1",
          nativeQuery = true)
public Author fetchByIdSql(long id);

同样,被触发的SELECT返回的数据快照被忽略,返回的作者是来自持久上下文 A 的作者(马克·詹内尔)。会话级可重复读取按预期工作。

到目前为止,我们可以得出结论,对于通过 JPQL 或原生 SQL 表达的实体查询,Hibernate 会话级可重复读取可以像预期的那样工作。接下来,让我们看看这是如何与 SQL 查询投影一起工作的。

  • 我们执行下面的 JPQL 查询投影(fetchNameByIdJpql()):
@Query("SELECT a.name FROM Author a WHERE a.id = ?1")
public String fetchNameByIdJpql(long id);

这一次,被触发的SELECT返回的数据快照没有被忽略。返回的作者有名字艾丽西娅·汤姆。因此,会话级可重复读取在这种情况下不起作用。

  • 最后,我们执行下面的本地 SQL 查询投影(fetchNameByIdSql()):
@Query(value = "SELECT name FROM author WHERE id = ?1",
     nativeQuery = true)
public String fetchNameByIdSql(long id);

同样,由触发的SELECT返回的数据快照不会被忽略。返回的作者有名字艾丽西娅·汤姆。因此,会话级可重复读取不起作用。

到目前为止,我们可以得出结论,Hibernate 会话级可重复读取不适用于通过 JPQL 或原生 SQL 表达的 SQL 查询投影。这类查询总是加载最新的数据库状态。

然而,如果我们将事务隔离级别切换回REPEATABLE_READ,那么 SQL 查询投影将返回作者马克·詹纳。这是因为,顾名思义,REPEATABLE_READ隔离级别声明一个事务在多次读取中读取相同的结果。换句话说,REPEATABLE_READ隔离级别防止了 SQL 不可重复读取异常(附录 E )。例如,多次从数据库中读取一条记录的事务在每次读取时都会获得相同的结果(附录 F )。

不要混淆 Hibernate 会话级可重复读取和REPEATABLE_READ事务隔离级别。

好的,还有两个方面需要考虑:

Hibernate 提供了即时可用的会话级可重复读取。但是有时您会希望从数据库中加载最新的状态。在这种情况下,您可以调用EntityManager#refresh()方法(因为 Spring 数据没有公开这个方法,您可以扩展JpaRepository来添加它)。

不要将 Hibernate 会话级可重复读取与应用级可重复读取混淆,后者通常在对话跨越多个请求时使用(第 134 项)。Hibernate 保证了会话级的可重复读取,并提供了对应用级可重复读取的支持。更准确地说,持久性上下文保证了会话级的可重复读取,并且您可以通过分离的实体或扩展的持久性上下文来塑造应用级的可重复读取。应用级可重复读取应该得到应用级并发控制策略的帮助,比如乐观锁定,以避免更新丢失(参见附录 E )。

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

通过 ID 直接提取多个实体

有时,您需要通过 ID 加载多个实体。在这种情况下,通过 ID 加载实体的最快方法将依赖于使用IN操作符的查询。Spring Data 提供了现成的findAllById()方法。它将 id 的一个Iterable作为参数,并返回实体的一个List(Book是实体,BookRepository是该实体的经典 Spring 存储库):

List<Book> books = bookRepository.findAllById(List.of(1L, 2L, 5L));

通过 JPQL 可以获得相同的结果(相同的触发 SQL ),如下所示:

@Query("SELECT b FROM Book b WHERE b.id IN ?1")
List<Book> fetchByMultipleIds(List<Long> ids);

IN子句与支持执行计划缓存的数据库结合使用可以进一步优化,如第 122 项所示。

使用Specification也是一种选择。看看下面的例子:

List<Book> books = bookRepository.findAll(
    new InIdsSpecification(List.of(1L, 2L, 5L)));

其中InIdsSpecification是:

public class InIdsSpecification implements Specification<Book> {

    private final List<Long> ids;

    public InIdsSpecification(List<Long> ids) {
        this.ids = ids;
    }

    @Override
    public Predicate toPredicate(Root<Book> root,
        CriteriaQuery<?> cquery, CriteriaBuilder cbuilder) {

        return root.in(ids);

        // or
        // Expression<String> expression = root.get("id");
        // return expression.in(ids);
    }
}

这三种方法都触发相同的 SQL SELECT,并受益于会话级可重复读取。完整的应用可在 GitHub 3 上获得。

另一种方法是依赖 Hibernate 特有的MultiIdentifierLoadAccess接口。在它的优点中,这个接口允许您通过 ID 批量加载多个实体(withBatchSize()),并指定在执行数据库查询之前是否应该检查持久化上下文(默认情况下不检查,但是可以通过enableSessionCheck()启用)。由于MultiIdentifierLoadAccess是一个特定于 Hibernate 的 API,我们需要将其塑造成 Spring Boot 风格。GitHub 4 上有完整的应用。

第 22 项:每当您计划在未来的持久性上下文中将更改传播到数据库时,为什么要使用只读实体

考虑通过几个属性作为idnameagegenre形成作者简介的Author实体。该场景要求您加载一个Author概要文件,编辑该概要文件(例如,修改年龄),并将其保存回数据库。您不会在单个事务中这样做(持久性上下文)。您可以在两个不同的事务中完成,如下所示。

以读写模式加载作者

由于应该修改Author实体,您可能认为应该以读写模式加载它,如下所示:

@Transactional
public Author fetchAuthorReadWriteMode() {

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

    return author;
}

注意,获取的作者在方法(事务)中没有被修改。它被获取并返回,因此当前的持久化上下文在任何修改之前被关闭,返回的author被分离。让我们看看在持久性上下文中有什么。

提取读写实体后的持久性上下文:

Total number of managed entities: 1
Total number of collection entries: 0

EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, age=34, name=Joana Nimar, genre=History}
Entity name: com.bookstore.entity.Author
Status: MANAGED
State: [34, History, Joana Nimar]

请注意突出显示的内容。实体的状态为MANAGED,并且还存在水合状态。换句话说,这种方法至少有两个缺点:

  • Hibernate 准备好将实体更改传播到数据库(即使我们在当前的持久化上下文中没有修改),所以它在内存中保持水合状态。

  • 在刷新时,Hibernate 将扫描这个实体的修改,这次扫描也将包括这个实体。

性能损失反映在内存和 CPU 上。存储不需要的水合状态会消耗内存,而在刷新时扫描实体并由垃圾收集器收集会消耗 CPU 资源。最好通过以只读模式获取实体来避免这些缺点。

以只读模式加载作者

由于在当前持久化上下文中没有修改Author实体,所以它可以以只读模式加载,如下所示:

@Transactional(readOnly = true)
public Author fetchAuthorReadOnlyMode() {

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

    return author;
}

此方法(事务)加载的实体是只读实体。不要将只读实体与 DTO(投影)混淆。只读实体只能被修改,因此修改将在将来的持久性上下文中传播到数据库。DTO(投影)永远不会加载到持久性上下文中,它适用于永远不会被修改的数据。

让我们来看看这种情况下的持久性上下文内容。

提取只读实体后的持久性上下文:

Total number of managed entities: 1
Total number of collection entries: 0

EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, age=34, name=Joana Nimar, genre=History}
Entity name: com.bookstore.entity.Author
Status: READ_ONLY
State: null

这次状态是READ_ONLY,水合状态被丢弃。此外,没有自动冲洗时间,也没有应用脏检查。这比以读写模式获取实体要好得多。我们不会为了存储水合状态而消耗内存,也不会因为不必要的动作而消耗 CPU。

更新作者

在获取和返回实体(在读写或只读模式下)后,它被分离。此外,我们可以对其进行修改和合并:

// modify the read-only entity in detached state
Author authorRO = bookstoreService.fetchAuthorReadOnlyMode();
authorRO.setAge(authorRO.getAge() + 1);
bookstoreService.updateAuthor(authorRO);

// merge the entity
@Transactional
public void updateAuthor(Author author) {

    // behind the scene it calls EntityManager#merge()
    authorRepository.save(author);
}

author不是当前的持久上下文,这是一个合并操作。因此,这个动作在一个SELECT和一个UPDATE中被具体化。

此外,合并后的实体由 Hibernate 管理。完整的代码可以在 GitHub 5 上找到。

请注意,这里展示的案例使用了每个请求的持久性上下文习惯用法。持久性上下文被绑定到单个物理数据库事务和单个逻辑@Transactional的生命周期。如果您选择使用扩展的持久性上下文,那么实现将由其他规则控制。然而,在 Spring 中使用扩展持久性上下文是相当具有挑战性的。如果你不完全确定你理解它,最好避免它。

本文介绍的场景在 web 应用中很常见,被称为 HTTP 长对话。通常,在 web 应用中,这种场景需要两个或更多的 HTTP 请求。特别是在这种情况下,第一个请求将加载作者配置文件,而第二个请求将推送配置文件更改。在 HTTP 请求之间是作者思考的时间。这在第 134 项中有详细说明。

第 23 项:如何通过 Hibernate 字节码增强来延迟加载实体属性

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    @Lob
    private byte[] avatar;

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

    // getters and setters omitted for brevity
}

启用属性的延迟加载

诸如实体标识符(id)、nameagegenre之类的属性将在每次实体加载时被急切地获取。但是avatar应该被延迟获取,只有当它被应用代码访问时。因此,avatar列不应该出现在被触发来获取Author的 SQL 中。

在图 3-1 中,可以看到author表的avatar栏。

img/487471_1_En_3_Fig1_HTML.jpg

图 3-1

头像要装懒

默认情况下,一个实体的属性被急切地加载(在同一个查询中一次全部加载),所以即使应用不需要/不要求,也会加载avatar

avatar代表一张图片;因此,这是一个潜在的大量字节数据(例如,在图 3-1 中,头像占用了 5086 字节)。在每次实体加载时加载头像而不使用它是一种性能损失,应该消除。

这个问题的解决方案依赖于属性延迟加载

属性延迟加载对于存储大量数据的列类型非常有用,如CLOBBLOBVARBINARY等。—或者用于需要加载的细节

要使用属性延迟加载,您需要遵循一些步骤。第一步是为 Maven 添加 Hibernate 字节码增强插件。接下来,通过enableLazyInitialization配置启用惰性初始化,指示 Hibernate 用正确的指令来插装实体类的字节码(如果您想看看添加的指令,那么只需反编译插装的实体类)。对于 Maven,将pom.xml添加到字节码增强插件的<plugins>部分,如下所示:

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

Hibernate 字节码增强发生在构建时;因此,它不会增加运行时的开销。如果不像这里所示的那样添加字节码增强,属性 lazy loading 将不起作用。

第二步包括注释应该用@Basic(fetch = FetchType.LAZY惰性加载的实体属性。对于Author实体,将avatar属性注释如下:

@Lob
@Basic(fetch = FetchType.LAZY)
private byte[] avatar;

默认情况下,属性用@Basic标注,这依赖于默认的获取策略。默认的获取策略是FetchType.EAGER

此外,可以为Author实体编写一个经典的 Spring 存储库。最后,出于好玩,添加一个查询来获取所有大于或等于给定年龄的作者:

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Transactional(readOnly=true)
    List<Author> findByAgeGreaterThanEqual(int age);
}

下面的服务方法将加载所有超过给定年龄的作者。不会加载avatar属性:

public List<Author> fetchAuthorsByAgeGreaterThanEqual(int age) {
    List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);

    return authors;
}

调用这个方法将显示一个只获取idnameagegenre的 SQL:

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre4_0_,
  author0_.name AS name5_0_
FROM author author0_
WHERE author0_.age >= ?

从返回的作者列表中选取一个作者id并将其传递给下面的方法,也将获取avatar属性。对getAvatar()方法的显式调用将触发一个二级 SQL 来加载虚拟角色的字节:

@Transactional(readOnly = true)
public byte[] fetchAuthorAvatarViaId(long id) {

    Author author = authorRepository.findById(id).orElseThrow();
    return author.getAvatar(); // lazy loading of 'avatar'
}

用给定的id获取作者是在两个SELECT语句中完成的。第一个SELECTidagenamegenre,第二个SELECTavatar:

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

SELECT
  author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?

试图在会话上下文之外(持久性上下文之外)获取惰性属性(例如,avatar)将导致LazyInitializationException

属性延迟加载和 N+1

N+1 表示由于触发的 SQL 语句(查询)比所需/预期的多而导致的性能损失。换句话说,执行不必要的数据库往返会消耗 CPU、RAM 内存、数据库连接等资源。大多数情况下,直到您检查(计数/断言)被触发的 SQL 语句的数量时,N+1 才被检测到。

额外的和不必要的 SQL 语句越多,应用就越慢。

考虑以下方法:

@Transactional(readOnly = true)
public List<Author> fetchAuthorsDetailsByAgeGreaterThanEqual(int age) {

    List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);

    // don't do this since this is a N+1 case
    authors.forEach(a -> {
        a.getAvatar();
    });

    return authors;
}

通过调用findByAgeGreaterThanEqual()触发的查询获取一个比给定年龄更老的作者列表(这是 N+1 中的 1)。循环作者列表并为每个作者调用getAvatar()会导致与作者数量相等的额外查询。换句话说,由于头像是被延迟获取的,调用getAvatar()将为每个作者触发一个 SQL SELECT(这是 N+1 中的 N)。对于两个作者,我们有以下三个 SQL 语句(最后两个查询是获取化身所需的附加查询):

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre4_0_,
  author0_.name AS name5_0_
FROM author author0_
WHERE author0_.age >= ?

SELECT
  author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?

SELECT
  author_.avatar AS avatar3_0_
FROM author author_
WHERE author_.id = ?

通过采用子实体技术(参见第 24 条)或者通过触发一个 SQL SELECT来显式加载 DTO 中的延迟获取属性,可以避免 N+1 的性能损失。例如,下面的查询将触发单个SELECT来获取年龄大于给定年龄的作者的姓名和头像作为 DTO (Spring projection):

public interface AuthorDto {

    public String getName();
    public byte[] getAvatar();
}

@Transactional(readOnly = true)
@Query("SELECT a.name AS name, a.avatar AS avatar
      FROM Author a WHERE a.age >= ?1")
List<AuthorDto> findDtoByAgeGreaterThanEqual(int age);

GitHub 6 上有源代码。

属性延迟加载和延迟初始化异常

在 Spring Boot 应用中启用属性延迟加载最终会导致特定于该上下文的延迟初始化异常。通常,当开发人员在视图中禁用 Open Session(在 Spring Boot 默认情况下是启用的)时,就会发生这种情况。让我们处理一个典型的场景。

默认情况下,View 中的 Open Session 强制当前的持久性上下文保持打开,而 Jackson 强制惰性加载属性的初始化(一般来说,View 层触发代理初始化)。例如,如果启用了 Open Session in View,并且应用从 REST 控制器端点返回一个List<Author>,那么视图(Jackson 序列化 JSON 响应)也将强制初始化avatar属性。OSIV 将提供当前活动的Session,因此不会出现懒惰初始化问题。

即使这是另一个话题,考虑以下问题:通过 REST API 公开实体是否明智?我建议你读一读索本·让桑的这篇文章 7

显然,这违背了应用的目标。解决方案包括通过在application.properties中设置以下内容来禁用 OSIV:

spring.jpa.open-in-view=false

但这导致了一个例外。这一次,当 Jackson 试图将List<Author>序列化为 JSON(这是应用的客户端通过控制器端点接收的数据)时,将没有活动的Session可用。

最有可能的例外如下:

Could not write JSON: Unable to perform requested lazy initialization [com.bookstore.entity.Author.avatar] - no session and settings disallow loading outside the Session;

因此,Jackson 在没有 Hibernate 会话的情况下强制初始化惰性加载的属性,这导致了惰性初始化异常。另一方面,此时没有活动的 Hibernate 会话也没什么问题。

至少有两种方法可以解决这个问题,并且仍然可以利用属性延迟加载。

为延迟加载的属性设置显式默认值

一种快速的方法是为延迟加载的属性显式设置默认值。如果 Jackson 发现惰性加载的属性已经用值初始化了,那么它不会尝试初始化它们。考虑以下方法:

@Transactional(readOnly = true)
public Author fetchAuthor(long id) {

    Author author = authorRepository.findById(id).orElseThrow();

    if (author.getAge() < 40) {
        author.getAvatar();
    } else {
        author.setAvatar(null);
    }

    return author;
}

该方法通过id获取一个作者,如果获取的作者小于 40 岁,它通过二级查询加载化身。否则,用null初始化avatar属性。这一次,Jackson 序列化不会导致任何问题,但是客户端收到的 JSON 可能如下所示:

{
  "id": 1,
  "avatar": null,
  "age": 43,
  "name": "Martin Ticher",
  "genre": "Horror"
}

现在,根据实现的特性,您可能希望将化身序列化为null,或者指示 Jackson 不要序列化具有默认值的属性(例如,对于对象为null,对于原始整数为0,等等)。).最常见的是,应用应该避免avatar的序列化;因此,设置@JsonInclude(Include.NON_DEFAULT)是实体级需要的设置。在这个设置中,Jackson 将跳过任何具有默认值的属性的序列化(根据您的情况,也可以使用其他值的Include,比如Include.NON_EMPTY):

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
...
@Entity
@JsonInclude(Include.NON_DEFAULT)
public class Author implements Serializable {
    ...
}

这一次,生成的 JSON 不包含avatar:

{
  "id": 1,
  "age": 43,
  "name": "Martin Ticher",
  "genre": "Horror"
}

为延迟加载的属性设置显式默认值可以防止视图触发它们的延迟加载。从这个角度来看,启用或禁用 OSIV 并不重要,因为不会使用Session。然而,Session仍然是开放的,消耗资源,所以建议禁用 OSIV。

GitHub 8 上有源代码。

提供自定义杰克逊过滤器

或者,可以通过自定义过滤器通知 Jackson 哪些应该序列化,哪些不应该序列化。在这种情况下,杰克森应该连载idagenamegenre,而不连载avatar

假设下面的服务方法,它简单地获取超过给定年龄的作者,而不获取他们的头像:

public List<Author> fetchAuthorsByAgeGreaterThanEqual(int age) {

    List<Author> authors = authorRepository.findByAgeGreaterThanEqual(age);

    return authors;
}

有几种方法可以编写和配置杰克逊过滤器。

一种方法是从用@JsonFilter注释实体开始,如下所示(引号之间的文本作为这个过滤器的标识符,用于以后引用它):

@Entity
@JsonFilter("AuthorId")
public class Author implements Serializable {
    ...
}

通过AuthorId识别的过滤器在BookstoreController中实现,如下(重要部分被突出显示;注意应该被序列化并传递给filterOutAllExcept()方法的属性列表):

@Controller
public class BookstoreController {

    private final SimpleFilterProvider filterProvider;
    private final BookstoreService bookstoreService;

    public BookstoreController(BookstoreService bookstoreService) {
        this.bookstoreService = bookstoreService;

        filterProvider = new SimpleFilterProvider().addFilter("AuthorId",
        SimpleBeanPropertyFilter.filterOutAllExcept(
            "id", "name", "age", "genre"));
        filterProvider.setFailOnUnknownId(false);
    }
    ...
}

过滤器在 REST 端点中的使用如下:

@GetMapping("/authors/{age}")
public MappingJacksonValue fetchAuthorsByAgeGreaterThanEqual(
           @PathVariable int age) throws JsonProcessingException {

    List<Author> authors = bookstoreService.
        fetchAuthorsByAgeGreaterThanEqual(age);

    MappingJacksonValue wrapper = new MappingJacksonValue(authors);
    wrapper.setFilters(filterProvider);

    return wrapper;
}

返回的MappingJacksonValue可以序列化,如下面的 JSON 所示:

{
  "id": 1,
  "age": 43,
  "name": "Martin Ticher",
  "genre": "Horror"
}

这看起来不错,但是应用还必须涵盖获取avatar属性的情况。否则,Jackson 将抛出类型为Cannot resolve PropertyFilter with id 'AuthorId'的异常。当获取avatar时,它也应该被序列化。因此,筛选器应该序列化所有属性。作为默认行为,过滤器可以被全局配置(在应用级别)以用于序列化Author实体的所有属性:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

    @Override
    protected void extendMessageConverters(
                  List<HttpMessageConverter<?>> converters) {

        for(HttpMessageConverter<?> converter: converters) {
            if(converter instanceof MappingJackson2HttpMessageConverter) {
                ObjectMapper mapper = ((MappingJackson2HttpMessageConverter)
                    converter).getObjectMapper();
                mapper.setFilterProvider(
                    new SimpleFilterProvider().addFilter("AuthorId",
                        SimpleBeanPropertyFilter.serializeAll()));
            }
        }
    }
}

将返回一个List<Author>的 REST 端点将依赖这个过滤器来序列化Author的所有属性,包括avatar .

Jackson 为 JSON 处理器提供了一个附加模块,用于处理 Hibernate 数据类型,特别是延迟加载方面( Item 110 )。该模块由工件 idjackson-datatype-hibernate5标识。不幸的是,到目前为止,这个模块对延迟加载的属性没有影响。它负责懒惰的加载关联。

GitHub 9 上有源代码。

第 24 项:如何通过子实体惰性加载实体属性

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    @Lob
    private byte[] avatar;

    private int age;
    private String name;
    private String genre;
    ...
    // getters and setters omitted for brevity
}

该项目显示了对项目 23 的替代方案**;因此,目标是急切地加载idagenamegenre,并懒洋洋地引导avatar(仅按需)。这种方法基于将Author实体分割成子实体,如图 3-2 所示。**

img/487471_1_En_3_Fig2_HTML.jpg

图 3-2

通过子实体的属性延迟加载

图 3-2 中间的类是基类(这不是实体,在数据库中没有表)BaseAuthor,用@MappedSuperclass标注。该注释标记一个类,其映射信息应用于从该类继承的实体。因此,BaseAuthor应该托管那些被急切加载的属性(idagenamegenre)。BaseAuthor的每个子类都是继承这些属性的实体;因此,载入子类也将载入这些属性:

@MappedSuperclass
public class BaseAuthor implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

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

    // getters and setters omitted for brevity
}

AuthorShallowBaseAuthor的子实体。这个子实体继承了超类的属性。因此,应该急切地加载所有属性。通过@Table注释将这个子实体显式映射到author表是很重要的:

@Entity
@Table(name = "author")
public class AuthorShallow extends BaseAuthor {
}

AuthorDeep也是BaseAuthor的子实体。这个子实体继承了超类的属性,并定义了avatar。通过@Table显式映射该子实体,avatar也位于author表中,如下所示:

@Entity
@Table(name = "author")
public class AuthorDeep extends BaseAuthor {

    @Lob
    private byte[] avatar;

    public byte[] getAvatar() {
        return avatar;
    }

    public void setAvatar(byte[] avatar) {
        this.avatar = avatar;
    }
}

如果子实体没有通过@Table显式映射到同一个表,那么属性将位于不同的表中。此外,继承的属性将被复制。例如,如果没有@Table(name = "author")idnameagegenre将会在一个名为author_shallow的表和一个名为author_deep的表中着陆。另一方面,avatar将只落在author_deep工作台。显然,这样不好。

此时,AuthorShallow允许急切地获取idagenamegenre,而AuthorDeep允许获取这四个属性加上avatar。总之,avatar可以按需加载。

下一步很简单。只需为这两个子实体提供经典的 Spring 存储库,如下所示:

@Repository
public interface AuthorShallowRepository
           extends JpaRepository<AuthorShallow, Long> {
}

@Repository
public interface AuthorDeepRepository
           extends JpaRepository<AuthorDeep, Long> {
}

AuthorShallowRepository调用findAll()将触发下面的 SQL(注意avatar没有被加载):

SELECT
  authorshal0_.id AS id1_0_,
  authorshal0_.age AS age2_0_,
  authorshal0_.genre AS genre3_0_,
  authorshal0_.name AS name4_0_
FROM author authorshal0_

AuthorDeepRepository调用findAll()将触发下面的 SQL(注意avatar已经加载):

SELECT
  authordeep0_.id AS id1_0_,
  authordeep0_.age AS age2_0_,
  authordeep0_.genre AS genre3_0_,
  authordeep0_.name AS name4_0_,
  authordeep0_.avatar AS avatar5_0_
FROM author authordeep0_

此时,一个结论开始形成。Hibernate 支持延迟加载属性(参见第 23 项),但是这需要字节码增强,并且需要处理 View 中的 Open Session 和 Jackson 序列化问题。另一方面,使用子实体可能是更好的选择,因为它不需要字节码增强,也不会遇到这些问题。

GitHub 10 上有源代码。

第 25 项:如何通过春季项目获得 DTO

从数据库获取数据会在内存中产生该数据的副本(通常称为结果集JDBC 结果集)。保存获取的结果集的内存区域被称为持久性上下文或一级缓存,或简称为缓存。默认情况下,Hibernate 以读写模式运行。这意味着获取的结果集作为Object[](更准确地说,作为特定于 Hibernate 的EntityEntry实例)存储在持久性上下文中,在 Hibernate 术语中称为混合状态,以及从这种混合状态构建的实体。水合状态服务于脏检查机制(在刷新时,Hibernate 将实体与水合状态进行比较,以发现潜在的更改/修改,并代表您触发UPDATE语句)、无版本乐观锁定机制(用于构建WHERE子句)和二级缓存(缓存的条目是从分解的水合状态构建的,或者更准确地说,是从 Hibernate 特定的CacheEntry实例构建的,该实例是从第一次分解的水合状态构建的)。

换句话说,在获取操作之后,获取的结果集位于数据库之外的内存中。应用通过实体(因此,通过 Java 对象)访问/管理这些数据,为了方便这个上下文,Hibernate 应用了几种特定的技术,将获取的 raw 数据(JDBC 结果集)转换成水合状态(这个过程称为水合,并进一步转换成可管理的表示(实体)。

**如果没有修改数据的计划,这是不以读写模式获取数据的一个很好的理由。**在这样的场景下,读写数据会白白消耗内存和 CPU 资源。这给应用增加了严重的性能损失。或者,如果您需要只读实体,那么切换到只读模式(例如,在 Spring 中,使用readOnly元素,@Transactional(readOnly=true))。这将指示 Hibernate 从内存中丢弃水合状态。此外,没有自动冲洗时间,也没有脏检查。只有实体保留在持久性上下文中。因此,这将节省存储器和 CPU 资源(例如,CPU 周期)。只读实体仍然意味着您计划在不久的将来的某个时候修改它们(例如,您不计划在当前的持久性上下文中修改它们,但是它们将在分离状态下被修改,并在以后合并到另一个持久性上下文中)。**如果您不打算修改数据,这是不要以只读模式获取数据的一个很好的理由。**但是,这里有一个例外,您可以考虑将只读实体作为镜像实体(包含所有列)的 dto 的替代。

根据经验,如果您需要的只是不会被修改的只读数据,那么使用数据传输对象(DTO)将只读数据表示为 Java 对象。大多数时候,dto 只包含实体属性的一个子集,这样可以避免获取不必要的数据(列)。不要忘记,除了跳过不需要的列之外,您应该考虑通过LIMIT或它的对应物来限制获取的行数。

出于各种原因,有些人会告诉您只获取实体来使用转换器/映射器创建 dto。在决定之前,考虑阅读弗拉德·米哈尔恰的推文 11 ,它也反对这种反模式。Vlad 说:“不要提取实体,只使用映射器来创建 dto。这是非常低效的,但我一直看到这种反模式得到推广。”

DTO 和春天的预测有着本质上相同的目的。Martin Folwer 将 DTO 定义为“在进程间携带数据以减少方法调用次数的对象”。在执行层面,DTO 和春季的预测是不一样的。DTO 依赖于带有构造函数和 getter/setter 的类,而 Spring 投影依赖于接口和自动生成的代理。然而,Spring 也可以依赖于类,其结果被称为 DTO 投影

假设我们有下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

目标是只获取具有相同genre的两位作者的nameage。这一次,应用依赖于弹簧投影。

Spring projection 可能以 Java 接口开始,该接口只包含应该从数据库获取的列的 getters(例如,nameage)。

这种类型的弹簧投影被称为基于接口的封闭投影(在这种投影中定义的方法与实体属性的名称完全匹配):

public interface AuthorNameAge {

    String getName();
    int getAge();
}

在幕后,Spring 为每个实体对象生成投影接口的代理实例。此外,对代理的调用会自动转发到该对象。

投影接口也可以声明为存储库接口的内部接口。它可以声明为static或非static,如下例所示:

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Transactional(readOnly = true)
    List<AuthorNameAge> findFirst2ByGenre(String genre);

    public static interface AuthorNameAge {

        String getName();
        int getAge();
    }
}

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

在这个投影中,只获取两个作者的正确查询是(利用 Spring 数据查询构建器机制或依赖 JPQL 或原生 SQL):

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

    List<AuthorNameAge> findFirst2ByGenre(String genre);
}

注意,这个查询返回的是一个List<AuthorNameAge>而不是一个List<Author>

为给定流派调用此方法将触发以下 SQL:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_
FROM author author0_
WHERE author0_.genre=?
LIMIT ?

提取的数据可以通过投影获取器进行操作,如下例所示:

List<AuthorNameAge> authors = ...;
for (AuthorNameAge author : authors) {
    System.out.println("Author name: " + author.getName()
                                    + " | Age: " + author.getAge());
}

GitHub 13 上有源代码。

使用投影并不局限于使用 Spring 数据存储库基础设施中内置的查询构建器机制。通过 JPQL 或原生 SQL 查询获取投影也是一种选择。例如,前面的查询可以通过本机 SQL 查询编写,如下所示:

@Query(value = "SELECT a.name, a.age FROM author a
                WHERE a.genre=?1 LIMIT 2", nativeQuery=true)

当列名与实体属性名不一致时,只需依靠 SQL AS关键字来定义相应的别名。例如,如果name属性被映射到author_name列,而age属性被映射到author_age列,那么本地 SQL 查询将如下所示:

@Query(value = "SELECT a.author_name AS name, a.author_age AS age
                FROM author a WHERE a.genre=?1 LIMIT 2",
                nativeQuery=true)

如果没有必要使用LIMIT那么就依赖 JPQL。在 GitHub 14 上,有一个使用 JPQL 和 Spring 投影的例子。

JPA 命名的(本地)查询可以与 Spring 投影结合使用

如果您不熟悉在 Spring Boot 应用中使用命名(本地)查询,那么我建议您推迟阅读本节,直到您阅读了 Item 127

假设您的项目中有一组命名查询,并且您想要利用 Spring Projection。下面是完成这项任务的一个例子。首先,使用@NamedQuery@NamedNativeQuery注释定义两个命名查询及其本地副本。第一个查询Author.fetchName,表示到List<String>的标量映射,而第二个查询Author.fetchNameAndAge,表示到List<AuthorNameAge>的弹簧投影映射:

@NamedQuery(
    name = "Author.fetchName",
    query = "SELECT a.name FROM Author a"
)

@NamedQuery(
    name = "Author.fetchNameAndAge",
    query = "SELECT a.age AS age, a.name AS name FROM Author a"
)
@Entity
public class Author implements Serializable {
    ...
}

@NamedNativeQuery(
    name = "Author.fetchName",
    query = "SELECT name FROM author"
)

@NamedNativeQuery(
    name = "Author.fetchNameAndAge",
    query = "SELECT age, name FROM author"
)
@Entity
public class Author implements Serializable {
    ...
}

或者,您可以通过一个jpa-named-queries.properties文件定义相同的查询(这是在非本地的命名查询中利用动态排序(Sort)的推荐方式)和在Pageable(在命名查询和命名本地查询中)中定义Sort:

# Find the names of authors
Author.fetchName=SELECT a.name FROM Author a

# Find the names and ages of authors
Author.fetchNameAndAge=SELECT a.age AS age, a.name AS name FROM Author a

和他们的本土对手:

# Find the names of authors
Author.fetchName=SELECT name FROM author

# Find the names and ages of authors
Author.fetchNameAndAge=SELECT age, name FROM author

或者,您可以通过orm.xml文件定义相同的查询(注意,这种方法与使用@NamedQuery@NamedNativeQuery有相同的缺点):

<!-- Find the names of authors -->
<named-query name="Author.fetchName">
    <query>SELECT a.name FROM Author a</query>
</named-query>

<!-- Find the names and ages of authors -->
<named-query name="Author.fetchNameAndAge">
    <query>SELECT a.age AS age, a.name AS name FROM Author a</query>
</named-query>

和他们的本土对手:

<!-- Find the names of authors -->
<named-native-query name="Author.fetchName">
    <query>SELECT name FROM author</query>
</named-native-query>

<!-- Find the names and ages of authors -->
<named-native-query name="Author.fetchNameAndAge">
    <query>SELECT age, name FROM author</query>
</named-native-query>

无论您喜欢哪种方法,AuthorRepository都是一样的:

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

    // Scalar Mapping
    List<String> fetchName();

    // Spring projection
    List<AuthorNameAge> fetchNameAndAge();
}

或者本地的对应物:

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

    // Scalar Mapping
    @Query(nativeQuery = true)
    List<String> fetchName();

    // Spring projection
    @Query(nativeQuery = true)
    List<AuthorNameAge> fetchNameAndAge();
}

仅此而已!Spring Boot 会自动为你做剩下的事情。根据提供命名(本地)查询的方式,您可以从以下应用中进行选择:

  • 如何通过@NamedQuery和弹簧投影 15 使用 JPA 命名查询

  • 如何通过@NamedNativeQuery和 Spring projection16使用 JPA 命名的本地查询

  • 如何通过属性文件和 Spring projection17使用 JPA 命名查询

  • 如何通过属性文件和 Spring projection18使用 JPA 命名的本地查询

  • 如何通过orm.xml文件和弹簧投影 19 使用 JPA 命名查询

  • 如何通过orm.xml文件和 Spring projection20使用 JPA 命名的原生查询

基于类别的投影

除了基于接口的投影,Spring 还支持基于类的投影。这一次,您编写了一个类,而不是一个接口。例如,AuthorNameAge接口变成了下面的AuthorNameAge类:

public class AuthorNameAge {

    private String name;
    private int age;

    public AuthorNameAge(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // getters, setters, equals() and hashCode() omitted for brevity
}

如您所见,构造函数的参数名必须与实体属性相匹配。

请注意,基于接口的投影可以嵌套,而基于类的投影则不能。

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

如何重复使用弹簧投影

这一次,考虑我们已经丰富了Author实体以包含以下属性:idnamegenreageemailaddressrating。或者,一般来说,一个拥有大量属性的实体。当一个实体有大量属性时,我们可能需要一组只读查询来获取不同的属性子集。例如,一个只读查询可能需要获取agenamegenreemailaddress,而另一个查询可能需要获取age namegenre,还有一个查询可能只需要获取nameemail

为了满足这三个查询,我们可以定义三个基于接口的弹簧闭合投影。这不太实际。例如,稍后,我们可能还需要一个只读查询来获取nameaddress。按照这个逻辑,我们还需要再定义一个弹簧投影。更实际的做法是定义一个单一的 Spring 投影,用于针对作者执行的所有只读查询。

为了完成这个任务,我们定义了一个 Spring projection,它包含一些 getters 来满足最繁重的查询(在本例中,查询获取了agenamegenreemailaddress):

@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public interface AuthorDto {

    public Integer getAge();
    public String getName();
    public String getGenre();
    public String getEmail();

    public String getAddress();
}

投影用@JsonInclude(JsonInclude.Include.NON_DEFAULT)标注。这是为了避免序列化null值(在当前查询中没有提取的值)。这将指示 Jackson 序列化机制从生成的 JSON 中跳过null值。

现在,我们可以依靠 Spring 数据查询构建器机制来生成获取agenamegenreemailaddress的查询,如下所示:

List<AuthorDto> findBy();

或者,您可以编写如下 JPQL:

@Query("SELECT a.age AS age, a.name AS name, a.genre AS genre, "
     + "a.email AS email, a.address AS address FROM Author a")
List<AuthorDto> fetchAll();

调用fetchAll()并将结果表示为 JSON 将产生以下结果:

[
   {
     "genre":"Anthology",
     "age":23,
     "email":"markj@gmail.com",
     "name":"Mark Janel",
     "address":"mark's address"
   },
   ...
]

此外,您可以在查询中重用AuthorDto投影,只获取agenamegenre:

@Query("SELECT a.age AS age, a.name AS name, a.genre AS genre FROM Author a")
List<AuthorDto> fetchAgeNameGenre();

调用fetchAgeNameGenre()并将结果表示为 JSON 将产生如下结果:

[
   {
     "genre":"Anthology",
     "age":23,
     "name":"Mark Janel"
   },
   ...
]

或者,您可以将AuthorDto投影重新用于只获取nameemail的查询:

@Query("SELECT a.name AS name, a.email AS email FROM Author a")
List<AuthorDto> fetchNameEmail();

调用fetchNameEmail()并将结果表示为 JSON 将产生如下结果:

[
   {
     "email":"markj@gmail.com",
     "name":"Mark Janel"
   },
   ...
]

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

如何使用动态弹簧投影

考虑上一节中的Author实体,它具有以下属性:idnamegenreageemailaddressrating。此外,考虑此图元的两个弹簧投影,定义如下:

public interface AuthorGenreDto {

    public String getGenre();
}

public interface AuthorNameEmailDto {

    public String getName();
    public String getEmail();
}

通过编写三个查询,您可以通过相同的查询方法获取实体类型、AuthorGenreDto类型和AuthorNameEmailDto类型,如下所示:

Author findByName(String name);

AuthorGenreDto findByName(String name);

AuthorNameEmailDto findByName(String name);

您实际上编写了相同的查询方法来返回不同的类型。这有点麻烦,Spring 通过动态的 ?? 预测来处理这种情况。您可以通过使用Class参数声明一个查询方法来应用动态预测,如下所示:

<T> T findByName(String name, Class<T> type);

这里还有两个例子:

<T> List<T> findByGenre(String genre, Class<T> type);
@Query("SELECT a FROM Author a WHERE a.name=?1 AND a.age=?2")
<T> T findByNameAndAge(String name, int age, Class<T> type);

这一次,根据您期望返回的类型,您可以如下调用findByName():

Author author = authorRepository.findByName(
    "Joana Nimar", Author.class);

AuthorGenreDto author = authorRepository.findByName(
    "Joana Nimar", AuthorGenreDto.class);

AuthorNameEmailDto author = authorRepository.findByName(
    "Joana Nimar", AuthorNameEmailDto.class);

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

第 26 项:如何在弹簧投影中添加图元

如果您不熟悉弹簧投影,那么可以考虑在继续之前阅读上一条。

通常,弹簧投影(DTO)用于获取只读数据。但是在有些情况下,应用需要获取 Spring 投影中的一个实体。对于这种情况,本例中突出显示了需要遵循的步骤。

物化联想

考虑双向惰性@OneToMany关联中涉及的AuthorBook实体。

弹簧投影应该映射Author实体,并且从Book实体,只映射title属性。基于上一项,弹簧投影接口可以编写如下:

public interface BookstoreDto {

    public Author getAuthor();
    public String getTitle();
}

获取数据是通过 JPQL 在下面的存储库中完成的(获取的数据存放在一个List<BookstoreDto>):

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

    @Query("SELECT a AS author, b.title AS title
        FROM Author a JOIN a.books b")
    List<BookstoreDto> fetchAll();
}

调用此方法将触发以下 SQL:

SELECT
  author0_.id AS col_0_0_,
  books1_.title AS col_1_0_,
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id

下面的服务方法在读写事务中调用fetchAll()。注意,获取的Author实例由 Hibernate 管理,潜在的更改将通过脏检查机制传播到数据库(Hibernate 将代表您触发UPDATE语句):

@Transactional
public List<BookstoreDto> fetchAuthors() {
    List<BookstoreDto> dto = authorRepository.fetchAll();

    // the fetched Author are managed by Hibernate
    // the following line of code will trigger an UPDATE
    dto.get(0).getAuthor().setGenre("Poetry");

    return dto;
}

将获取的数据显示到控制台非常简单:

List<BookstoreDto> authors = ...;
authors.forEach(a -> System.out.println(a.getAuthor()
                                    + ", Title: " + a.getTitle()));

GitHub 24 上有源代码。

非物化关联

这一次,考虑在AuthorBook实体之间没有具体化的关联。然而,如图 3-3 所示,两个实体共享一个genre属性。

img/487471_1_En_3_Fig3_HTML.jpg

图 3-3

没有物化关联

该属性可用于连接AuthorBook,并在同一个弹簧投影BookstoreDto中获取数据。这一次,JPQL 使用genre属性来连接这两个表,如下所示:

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

    @Query("SELECT a AS author, b.title AS title FROM Author a
            JOIN Book b ON a.genre=b.genre ORDER BY a.id")
    List<BookstoreDto> fetchAll();
}

调用fetchAll()将触发下面的 SQL:

SELECT
  author0_.id AS col_0_0_,
  book1_.title AS col_1_0_,
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
INNER JOIN book book1_
  ON (author0_.genre = book1_.genre)
ORDER BY author0_.id

下面的服务方法在读写事务中调用fetchAll()。请注意,提取的Author是托管的,Hibernate 会将这些Author的修改传播到数据库:

@Transactional
public List<BookstoreDto> fetchAuthors() {

    List<BookstoreDto> dto = authorRepository.fetchAll();

    // the fetched Author are managed by Hibernate
    // the following line of code will trigger an UPDATE
    dto.get(0).getAuthor().setAge(47);

    return dto;
}

将获取的数据显示到控制台非常简单:

List<BookstoreDto> authors = ...;
authors.forEach(a -> System.out.println(a.getAuthor()
                                + ", Title: " + a.getTitle()));

GitHub 25 上有源代码。

项目 27:如何用属于/不属于实体的虚拟属性来丰富 Spring 投影

在继续之前,考虑阅读第 25 项。

Spring 投影可以用属于或不属于域模型的虚拟属性来丰富。通常,当它们不是域模型的一部分时,它们是在运行时通过 SpEL 表达式计算的。

一个基于接口的 Spring 投影被称为基于接口的开放投影,它包含的方法在域模型中具有不匹配的名称,并且在运行时计算返回。

例如,下面的弹簧投影包含三个虚拟属性(yearsrankbooks):

public interface AuthorNameAge {

    String getName();

    @Value("#{target.age}")
    String years();

    @Value("#{ T(java.lang.Math).random() * 10000 }")
    int rank();

    @Value("5")
    String books();
}

在 Spring 投影中,AuthorNameAge依赖于@Value和 Spring SpEL 来指向来自域模型的支持属性(在这种情况下,域模型属性age通过虚拟属性属性years公开)。此外,使用@Value和 Spring SpEL 通过两个在域模型中不匹配的虚拟属性(在本例中是rankbooks)来丰富结果。

Spring 存储库非常简单,它包含一个查询,获取比给定年龄更老的作者nameage:

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

    @Query("SELECT a.name AS name, a.age AS age
            FROM Author a WHERE a.age >= ?1")
    List<AuthorNameAge> fetchByAge(int age);
}

针对给定年龄调用fetchByAge()将触发以下 SQL:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_
FROM author author0_
WHERE author0_.age >= ?

打印提取的数据使用years()代替agerank()books():

List<AuthorNameAge> authors = ...;

for (AuthorNameAge author : authors) {
    System.out.println("Author name: " + author.getName()
        + " | Age: " + author.years()
        + " | Rank: " + author.rank()
        + " | Books: " + author.books());
}

控制台的输出是(从数据库中获取作者的姓名和年龄):

Author name: Olivia Goy | Age: 43 | Rank: 3435 | Books: 5
Author name: Quartis Young | Age: 51 | Rank: 2371 | Books: 5
Author name: Katy Loin | Age: 56 | Rank: 2826 | Books: 5

GitHub 26 上有源代码。

第 28 项:如何有效地获取包含一对一关联的弹簧投影

假设AuthorBook再次涉及双向懒惰@OneToMany关联。您想获取一个只读的结果集,其中包含每本书的title和作者的namegenre。这样一个只读的结果集是 DTO 的完美候选,而且,在春天,获取这个 DTO 的主要方式涉及到春天的投影。

让我们使用图 3-4 所示的数据快照。

img/487471_1_En_3_Fig4_HTML.jpg

图 3-4

数据快照

使用嵌套闭合投影

图书title取自book表,而作者namegenre取自author表。这意味着您可以编写基于接口的嵌套弹簧闭合投影,如下所示:

public interface BookDto {

    public String getTitle();
    public AuthorDto getAuthor();

    interface AuthorDto {

        public String getName();
        public String getGenre();
    }
}

现在您所需要的就是适当的查询来填充这个 Spring 投影。最快的方法依赖于 Spring 数据查询构建器机制,如下所示:

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

    List<BookDto> findBy();
}

从实现的角度来看,这真的很快!但是,这种方法有效吗?让我们把结果集看作一个 JSON 表示(假设这是由一个 REST 控制器端点返回的):

[
   {
      "title":"A History of Ancient Prague",
      "author":{
         "genre":"History",
         "name":"Joana Nimar"
      }
   },
   {
      "title":"A People's History",
      "author":{
         "genre":"History",
         "name":"Joana Nimar"
      }
   },
   ...
]

是的,起作用了!但是有效率吗?如果不检查触发的 SQL 和持久性上下文内容,您可能会认为这种方法很棒。但是生成的SELECT获取的数据比需要的多(例如,您不需要作者的idage):

SELECT
  book0_.title AS col_0_0_,
  author1_.id AS col_1_0_,
  author1_.id AS id1_0_,
  author1_.age AS age2_0_,
  author1_.genre AS genre3_0_,
  author1_.name AS name4_0_
FROM book book0_
LEFT OUTER JOIN author author1_
  ON book0_.author_id = author1_.id

很明显,这个查询获取作者的所有属性(实体的属性越多,获取的不需要的数据就越多)。此外,如果您检查持久性上下文内容,您会注意到它包含三个处于READ_ONLY状态的条目,并且它们中没有一个处于水合状态(水合状态被丢弃,因为这个事务被标记为readOnly):

以下是持久性上下文内容:

Total number of managed entities: 3
Total number of collection entries: 3

EntityKey[com.bookstore.entity.Author#1]:
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
    Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, name=Joana Nimar, genre=History, age=34}

从数据库到投影的结果集部分通过持久化上下文。作者也被提取为只读实体。一般而言,数据量可能会影响性能(例如,相对大量的不需要的提取列和/或相对大量的提取行)。但是因为我们处于只读模式,所以在持久化上下文中没有水合状态,也不会对作者执行脏检查。然而,垃圾收集器需要在持久性上下文关闭后收集这些实例。

编写显式 JPQL 会产生与通过查询构建器机制生成的查询相同的输出):

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

    @Query("SELECT b.title AS title, a AS author "
        + "FROM Book b LEFT JOIN b.author a")
    // or as a INNER JOIN
    // @Query("SELECT b.title AS title, b.author AS author

FROM Book b")
    List<BookDto> findByViaQuery();
}

使用简单的封闭投影

依赖嵌套弹簧投影会导致性能下降。如何使用一个简单的弹簧闭合投影来获取原始数据,如下所示:

public interface SimpleBookDto {

    public String getTitle(); // of book
    public String getName();  // of author
    public String getGenre(); // of author
}

这一次,查询构建器机制无法帮助您。你可以写一个LEFT JOIN如下:

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

    @Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
        + "FROM Book b LEFT JOIN b.author a")
    List<SimpleBookDto> findByViaQuerySimpleDto();
}

这一次,结果集的 JSON 表示如下:

[
   {
      "title":"A History of Ancient Prague",
      "genre":"History",
      "name":"Joana Nimar"
   },
   {
      "title":"A People's History",
      "genre":"History",
      "name":"Joana Nimar"
   },
   ...
]

书籍和作者的数据是混杂的。根据具体情况,这种输出可以被接受(如本例所示)也可以不被接受。但是效率有多高呢?让我们看看触发的 SQL:

SELECT
  book0_.title AS col_0_0_,
  author1_.name AS col_1_0_,
  author1_.genre AS col_2_0_
FROM book book0_
LEFT OUTER JOIN author author1_
  ON book0_.author_id = author1_.id

该查询看起来与预期的完全一样。请注意,该查询只获取请求的列。此外,持久性上下文是空的。以下是持久性上下文内容:

Total number of managed entities: 0
Total number of collection entries: 0

从性能角度来看,这种方法比依赖嵌套弹簧投影要好。SQL 只获取所请求的列,并绕过持久性上下文。缺点是在数据表示中( raw 数据),它没有维护父子实体的树结构。在某些情况下,这不是问题;在其他情况下,它是。您必须根据需要处理这些数据(在服务器端或客户端)。当不需要进一步处理时,您甚至可以放弃投影并返回List<Object[]>:

@Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
     + "FROM Book b LEFT JOIN b.author a")
List<Object[]> findByViaQueryArrayOfObjects();

使用简单的开放投影

只要你不在乎维护数据结构(父子实体的树形结构),依靠一个简单的 Spring 闭投影是没问题的。如果这是一个问题,你可以依靠一个简单的弹簧打开投影。请记住第 27 条中的内容,开放投影允许您在域模型中使用不匹配的名称定义方法,并在运行时计算回报。本质上,开放投影支持虚拟属性。

这一次,我们按如下方式编写弹簧打开投影:

public interface VirtualBookDto {

    public String getTitle(); // of book

    @Value("#{@authorMapper.buildAuthorDto(target.name, target.genre)}")
    AuthorClassDto getAuthor();
}

突出显示的 SpEL 表达式引用 bean AuthorMapper,该 bean 调用buildAuthorDto()方法并转发投影namegenre作为方法参数。因此,在运行时,应该使用作者的namegenre来创建这里列出的AuthorClassDto的实例:

public class AuthorClassDto {

    private String genre;
    private String name;

    // getters, setters, equals() and hashCode() omitted for brevity
}

这项工作由名为AuthorMapper的助手类完成,如下所示:

@Component
public class AuthorMapper {

    public AuthorClassDto buildAuthorDto(String genre, String name) {
        AuthorClassDto authorClassDto = new AuthorClassDto();
        authorClassDto.setName(name);
        authorClassDto.setGenre(genre);

        return authorClassDto;
    }
}

这种实现的效率如何?值得努力吗?触发的 SQL 从以下 JPQL 中获得:

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

    @Query("SELECT b.title AS title, a.name AS name, a.genre AS genre "
         + "FROM Book b LEFT JOIN b.author a")
    List<VirtualBookDto> findByViaQueryVirtualDto();
}

SQL 看起来和预期的完全一样:

SELECT
  book0_.title AS col_0_0_,
  author1_.name AS col_1_0_,
  author1_.genre AS col_2_0_
FROM book book0_
LEFT OUTER JOIN author author1_
  ON book0_.author_id = author1_.id

持久性上下文没有被改动,如图所示。:

Total number of managed entities: 0
Total number of collection entries: 0

JSON 表示维护数据结构:

[
   {
      "title":"A History of Ancient Prague",
      "author":{
         "genre":"Joana Nimar",
         "name":"History"
      }
   },
   {
      "title":"A People's History",
      "author":{
         "genre":"Joana Nimar",
         "name":"History"
      }
   },
   ...
]

即使这比前面的方法需要更多的工作,依靠一个简单的 Spring open 投影也能保持数据结构。不幸的是,从图 3-5 中可以看出,这种方法的时间性能趋势更差。

图 3-5 所示的时间-性能趋势图将这四种方法与 100、500 和 1,000 名各有五本书的作者进行了比较。看起来获取原始数据是最快的方法,而使用开放投影是最慢的。

img/487471_1_En_3_Fig5_HTML.jpg

图 3-5

抓取@ManyToOne 关联为 DTO

图 3-5 中的时间-性能趋势图是针对 MySQL 在具有以下特征的 Windows 7 机器上获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。

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

第 29 项:为什么要关注包含相关系列的春季预测

假设AuthorBook参与了一个双向懒惰的@OneToMany关联。您想获取每个作者的namegenre,以及所有相关书籍的title。因为您需要一个包含来自 author 和 book 表的列子集的只读结果集,所以让我们尝试使用一个弹簧投影(DTO)。

让我们使用图 3-6 所示的数据快照。

img/487471_1_En_3_Fig6_HTML.jpg

图 3-6

数据快照

使用嵌套弹簧闭合投影

书籍title取自book表,而作者namegenre取自author表。这意味着您可以编写一个基于接口的、嵌套的 Spring closed 投影,如下所示(这种方法非常诱人,因为它很简单):

public interface AuthorDto {

    public String getName();
    public String getGenre();
    public List<BookDto> getBooks();

    interface BookDto {
        public String getTitle();
    }
}

注意,书名被映射为一个List<BookDto>。因此,调用AuthorDto#getBooks()应该会返回一个只包含书名的List<BookDto>

使用查询构建器机制

从实现的角度来看,填充投影的最快方法依赖于查询构建器机制,如下所示:

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

    List<AuthorDto> findBy();
}

这种方法有效吗?让我们把结果集看作一个 JSON 表示(假设这是由一个 REST 控制器端点返回的):

[
   {
      "genre":"Anthology",
      "books":[
         {
            "title":"The Beatles Anthology"
         }
      ],
      "name":"Mark Janel"
   },
   {
      "genre":"Horror",
      "books":[
         {
            "title":"Carrie"
         },
         {
            "title":"Nightmare Of A Day"
         }
      ],
      "name":"Olivia Goy"
   },
   {

      "genre":"Anthology",
      "books":[

      ],
      "name":"Quartis Young"
   },
   {
      "genre":"History",
      "books":[
         {
            "title":"A History of Ancient Prague"
         },
         {
            "title":"A People's History"
         },
         {
            "title":"History Now"
         }
      ],
      "name":"Joana Nimar"
   }
]

结果看起来很完美!因此,您已经使用了一个 Spring 投影和一个通过查询构建器机制生成的查询来获取一个只读结果集。这样有效率吗?您是否触发了单个SELECT查询?你已经设法绕过持久性上下文了吗?不不不。

检查触发的 SQL 查询揭示了以下内容:

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_

--  for each author there is an additional SELECT
SELECT
  books0_.author_id AS author_i4_1_0_,
  books0_.id AS id1_1_0_,
  books0_.id AS id1_1_1_,
  books0_.author_id AS author_i4_1_1_,
  books0_.isbn AS isbn2_1_1_,
  books0_.title AS title3_1_1_
FROM book books0_
WHERE books0_.author_id = ?

这个解决方案触发了五个SELECT语句!很明显,这是一个 N+1 问题。AuthorBook之间的关联是惰性的,Spring 需要获取作者和相关书籍作为实体,以便用请求的数据填充投影。持久性上下文内容也证实了这一点。

持久性上下文包含 10 个实体(其中 4 个是集合条目),状态为READ_ONLY,没有水合状态。

持久性上下文内容:

Total number of managed entities: 10
Total number of collection entries: 4

EntityKey[com.bookstore.entity.Book#1]:
    Book{id=1, title=A History of Ancient Prague, isbn=001-JN}
EntityKey[com.bookstore.entity.Book#3]:
    Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
    Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
    Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
    Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
    Book{id=6, title=Nightmare Of A Day, isbn=002-OG}

EntityKey[com.bookstore.entity.Author#1]:
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
    Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#3]:
    Author{id=3, name=Quartis Young, genre=Anthology, age=51}
EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, name=Joana Nimar, genre=History, age=34}

除了 N+1 问题之外,也不能忽略持久性上下文。所以,这种做法确实不好,应该避免。

使用显式 JPQL

您可以通过放弃查询构建器机制并采用显式 JPQL 来稍微改善一下情况,如下所示:

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

    @Query("SELECT a.name AS name, a.genre AS genre, b AS books "
         + "FROM Author a INNER JOIN a.books b")
    List<AuthorDto> findByViaQuery();
}

这次触发的是单个SELECT。根据 JPQL,书籍已完全加载,不仅仅是书名:

SELECT
  author0_.name AS col_0_0_,
  author0_.genre AS col_1_0_,
  books1_.id AS col_2_0_,
  books1_.id AS id1_1_,
  books1_.author_id AS author_i4_1_,
  books1_.isbn AS isbn2_1_,
  books1_.title AS title3_1_
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id

此外,持久性上下文由处于READ_ONLY状态的类型为Book的六个实体(没有集合条目)填充,并且没有水合状态(这一次,在持久性上下文中加载了更少的数据)。

持久性上下文内容:

Total number of managed entities: 6
Total number of collection entries: 0

EntityKey[com.bookstore.entity.Book#3]:
    Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
    Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
    Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
    Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
    Book{id=6, title=Nightmare Of A Day, isbn=002-OG}
EntityKey[com.bookstore.entity.Book#1]:
    Book{id=1, title=A History of Ancient Prague, isbn=001-JN}

而且,我们丢失了数据结构(父子实体的树形结构),每个标题都包装在自己的List:

[
   {
      "genre":"History",
      "books":[
         {
            "title":"A History of Ancient Prague"
         }
      ],
      "name":"Joana Nimar"
   },
   {
      "genre":"History",
      "books":[
         {
            "title":"A People's History"
         }
      ],
      "name":"Joana Nimar"
   },
   {
      "genre":"History",
      "books":[
         {
            "title":"History Now"
         }
      ],
      "name":"Joana Nimar"
   },
   {
      "genre":"Anthology",
      "books":[
         {
            "title":"The Beatles Anthology"
         }
      ],
      "name":"Mark Janel"
   },
   ...
]

作为一个小小的调整,您可以从嵌套投影中移除List,如下所示:

public interface AuthorDto {

    public String getName();
    public String getGenre();
    public BookDto getBooks();

    interface BookDto {
        public String getTitle();
    }
}

这不会创建List s,但是会很混乱。

使用 JPA 连接提取

正如 Item 39 所强调的,JOIN FETCH能够使用一个 SQL SELECT来初始化相关的集合以及它们的父对象。因此,您可以编写如下查询:

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

    @Query("SELECT a FROM Author a JOIN FETCH a.books")
    Set<AuthorDto> findByJoinFetch();
}

注意,这个例子使用了Set而不是List来避免重复。在这种情况下,添加 SQL DISTINCT子句不起作用。如果你添加一个ORDER BY子句(例如ORDER BY a.name ASC,在幕后,Hibernate 使用一个LinkedHashSet。因此,项目的顺序也得以保留。

调用findByJoinFetch()触发下面的SELECT(注意authorbook之间的INNER JOIN):

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_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id

这次触发的是单个SELECT。根据这个 SQL,作者和书籍被完全加载,而不仅仅是名称、流派和书名。让我们检查一下持久性上下文(我们有九个处于READ_ONLY状态且没有水合状态的实体,其中三个是集合条目)。这并不奇怪,因为根据其含义,JOIN FETCH获取实体,并与@Transactional(readOnly=true)结合,这导致只读实体。因此,Set<AuthorDto>是通过持久性上下文从这些实体中获得的。持久性上下文内容:

Total number of managed entities: 9
Total number of collection entries: 3

EntityKey[com.bookstore.entity.Book#3]:
    Book{id=3, title=History Now, isbn=003-JN}
EntityKey[com.bookstore.entity.Book#2]:
    Book{id=2, title=A People's History, isbn=002-JN}
EntityKey[com.bookstore.entity.Book#5]:
    Book{id=5, title=Carrie, isbn=001-OG}
EntityKey[com.bookstore.entity.Book#4]:
    Book{id=4, title=The Beatles Anthology, isbn=001-MJ}
EntityKey[com.bookstore.entity.Book#6]:
    Book{id=6, title=Nightmare Of A Day, isbn=002-OG}
EntityKey[com.bookstore.entity.Book#1]:
    Book{id=1, title=A History of Ancient Prague, isbn=001-JN}

EntityKey[com.bookstore.entity.Author#1]:
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
    Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, name=Joana Nimar, genre=History, age=34}

这一次,我们将数据保存为父子实体的树结构。以 JSON 的形式提取数据输出预期的结果,没有重复:

[
   {
      "genre":"Anthology",
      "books":[
         {
            "title":"The Beatles Anthology"
         }
      ],
      "name":"Mark Janel"
   },
   {
      "genre":"Horror",
      "books":[
         {
            "title":"Carrie"
         },
         {
            "title":"Nightmare Of A Day"
         }
      ],
      "name":"Olivia Goy"
   },
   {
      "genre":"History",
      "books":[
         {
            "title":"A History of Ancient Prague"
         },
         {
            "title":"A People's History"
         },
         {
            "title":"History Now"
         }
      ],
      "name":"Joana Nimar"
   }
]

正如您所看到的,JOIN FETCH维护了父子实体的树结构,但是与显式 JPQL 相比,它将更多不需要的数据带入了持久性上下文。这将如何影响整体性能取决于提取了多少不需要的数据,以及您对垃圾收集器的压力如何,垃圾收集器必须在持久性上下文被释放后清理这些对象。

使用简单的封闭投影

嵌套的弹簧投影容易造成性能损失。使用简单的弹簧闭合投影如何,如下所示:

public interface SimpleAuthorDto {

    public String getName();  // of author
    public String getGenre(); // of author
    public String getTitle(); // of book
}

和一个 JPQL,如下所示:

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

    @Query("SELECT a.name AS name, a.genre AS genre, b.title AS title "
         + "FROM Author a INNER JOIN a.books b")
    List<SimpleAuthorDto> findByViaQuerySimpleDto();
}

这一次,只有一个SELECT只获取请求的数据:

SELECT
  author0_.name AS col_0_0_,
  author0_.genre AS col_1_0_,
  books1_.title AS col_2_0_
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id

持久性上下文被绕过。持久性上下文内容:

Total number of managed entities: 0
Total number of collection entries: 0

但是,正如下面的 JSON 所揭示的,数据结构完全丢失了(这是原始数据):

[
   {
      "genre":"History",
      "title":"A History of Ancient Prague",
      "name":"Joana Nimar"
   },
   {
      "genre":"History",
      "title":"A People's History",
      "name":"Joana Nimar"
   },
   {
      "genre":"History",
      "title":"History Now",
      "name":"Joana Nimar"
   },
   ...
]

虽然这种方法只获取需要的数据,不涉及持久性上下文,但在数据表示级别上,它受到了严重的影响。在某些情况下,这不是问题,而在其他情况下则是问题。您必须处理这些数据,以便根据需要对其进行调整(在服务器端或客户端)。当不需要进一步处理时,您甚至可以放下投影返回List<Object[]>:

@Query("SELECT a.name AS name, a.genre AS genre, b.title AS title "
      + "FROM Author a INNER JOIN a.books b")
List<Object[]> findByViaArrayOfObjects();

DTO 的改造清单 您可以获取List<Object[]>并通过以下自定义转换器将其转换为 DTO:
@Component
public class AuthorTransformer {

    public List<AuthorDto> transform(List<Object[]> rs) {

        final Map<Long, AuthorDto> authorsDtoMap = new HashMap<>();

        for (Object[] o : rs) {

            Long authorId = ((Number) o[0]).longValue();

            AuthorDto authorDto = authorsDtoMap.get(authorId);
            if (authorDto == null) {
                authorDto = new AuthorDto();
                authorDto.setId(((Number) o[0]).longValue());
                authorDto.setName((String) o[1]);
                authorDto.setGenre((String) o[2]);
            }

            BookDto bookDto = new BookDto();
            bookDto.setId(((Number) o[3]).longValue());
            bookDto.setTitle((String) o[4]);

            authorDto.addBook(bookDto);
            authorsDtoMap.putIfAbsent(authorDto.getId(), authorDto);
        }

        return new ArrayList<>(authorsDtoMap.values());
    }
}

AuthorDtoBookDto是简单的 POJOs,定义如下:

public class AuthorDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long authorId;
    private String name;
    private String genre;

    private List<BookDto> books = new ArrayList<>();

    // constructors, getters, setters omitted for brevity

}

public class BookDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long bookId;
    private String title;

    // constructors, getters, setters omitted for brevity
}

为了编写一个简单的转换器,执行的查询还获取作者和书籍的 id。执行的查询如下所示:

@Repository
@Transactional(readOnly=true)
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a.id AS authorId, a.name AS name, a.genre AS genre, "
            + "b.id AS bookId, b.title AS title FROM Author a "
            + "INNER JOIN a.books b")
    List<Object[]> findByViaArrayOfObjectsWithIds();
}

服务方法执行查询并应用转换器,如下所示:

...
List<Object[]> authors = authorRepository.findByViaArrayOfObjectsWithIds();
List< AuthorDto> authorsDto = authorTransformer.transform(authors);
...

这一次,只有一个SELECT只获取请求的数据:

SELECT
  author0_.id AS col_0_0_,
  author0_.name AS col_1_0_,
  author0_.genre AS col_2_0_,
  books1_.id AS col_3_0_,
  books1_.title AS col_4_0_
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id

持久性上下文被绕过。持久性上下文内容:

Total number of managed entities: 0
Total number of collection entries: 0

DTO 的 JSON 表示看起来不错:

[
   {
      "name":"Mark Janel",
      "genre":"Anthology",
      "books":[
         {
            "title":"The Beatles Anthology",
            "id":4
         }
      ],
      "id":1
   },
   {
      "name":"Olivia Goy",
      "genre":"Horror",
      "books":[
         {
            "title":"Carrie",
            "id":5
         },
         {
            "title":"Nightmare Of A Day",
            "id":6
         }
      ],
      "id":2
   },
   {
      "name":"Joana Nimar",
      "genre":"History",
      "books":[

         {
            "title":"A History of Ancient Prague",
            "id":1
         },
         {
            "title":"A People's History",
            "id":2
         },
         {
            "title":"History Now",
            "id":3
         }
      ],
      "id":4
   }
]

图 3-7 显示了 100、500 和 1,000 名各有五本书的作者在这六种方法之间的直接比较。正如所料,查询构建器机制和嵌套投影具有更差的时间性能趋势。显式 JPQL 和JOIN FETCH的执行时间大致相同,但是请记住JOIN FETCH比显式 JPQL 获取更多不需要的数据。最后,一个原始投影——List<Object[]>List<Object[]>——在 DTO 转换后有几乎相同的执行时间。因此,为了只获取需要的数据并维护数据结构(父子实体的树形结构),最快的方法是依赖一个定制的List<Object[]>转换器。

img/487471_1_En_3_Fig7_HTML.jpg

图 3-7

正在提取关联的集合

图 3-7 中显示的时间-性能趋势图是针对 MySQL 在具有以下特征的 Windows 7 机器上获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。

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

第 30 项:如何通过弹簧投影获取所有实体属性

考虑一个具有以下四个属性的Author实体:idagegenrename。数据快照如图 3-8 所示。

img/487471_1_En_3_Fig8_HTML.jpg

图 3-8

数据快照

我们已经知道,通过基于接口/类的 Spring closed projection 获取包含这些属性子集的只读结果集非常简单(例如,只获取nameage)。

但是有时您需要一个包含所有实体属性的只读结果集(一个镜像实体的 DTO)。本节描述了几种基于只读实体和 Spring 预测的方法,并从性能角度强调了它们的优缺点。

因为您需要Author的所有属性,所以您可以轻松地触发一个只读查询,通过内置的findAll()方法将结果集作为实体获取:

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

内置的findAll()@Transactional(readOnly=true)标注。因此,持久性上下文将以只读模式用Author实体填充。

持久性上下文内容:

Total number of managed entities: 5

EntityKey[com.bookstore.entity.Author#1]:
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
    Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#3]:
    Author{id=3, name=Quartis Young, genre=Anthology, age=51}
EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, name=Joana Nimar, genre=History, age=34}
EntityKey[com.bookstore.entity.Author#5]:
    Author{id=5, name=Marin Kyrab, genre=History, age=33}

Entity name: com.bookstore.entity.Author | Status: READ_ONLY | State: null
Entity name: com.bookstore.entity.Author | Status: READ_ONLY | State: null
Entity name: com.bookstore.entity.Author | Status: READ_ONLY | State: null
Entity name: com.bookstore.entity.Author | Status: READ_ONLY | State: null
Entity name: com.bookstore.entity.Author | Status: READ_ONLY | State: null

只读模式指示 Hibernate 放弃水合状态。此外,没有自动冲洗时间,也没有脏检查。在本节的最后,我们将把这种方法与前面讨论的其他方法进行比较。

请记住,这是一个只读实体,而不是镜像实体并绕过持久性上下文的 DTO。只读实体的含义是它将在当前或后续请求中的某个点被修改(见第 22 项)。否则,它应该是一个投影(DTO)。

现在,让我们涉及一个 Spring 投影和不同的查询类型。让我们从基于接口的弹簧闭合投影开始,它包含相应的 getters:

public interface AuthorDto {

    public Long getId();
    public int getAge();
    public String getName();
    public String getGenre();
}

现在,让我们关注不同的查询类型。

使用查询构建器机制

一个简单的查询可以写成如下形式:

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

    List<AuthorDto> findBy();
}

调用findBy()将触发下面的SELECT语句:

SELECT
  author0_.id AS col_0_0_,
  author0_.age AS col_1_0_,
  author0_.name AS col_2_0_,
  author0_.genre AS col_3_0_
FROM author author0_

持久性上下文保持不变。持久性上下文内容:

Total number of managed entities: 0

这种方法很容易实现,而且非常高效。

作为一个提示,注意返回List<Object[]>而不是List<AuthorDto>是没有效率的,因为它也将在持久性上下文中加载数据。

使用 JPQL 和@Query

不适当的方法将依赖于@Query和 JPQL,如下所示:

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

    @Query("SELECT a FROM Author a")
    List<AuthorDto> fetchAsDto();
}

调用fetchAsDto()将触发下面的SELECT语句:

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_

这个SELECT与前一个方法中触发的完全相同,但是持久性上下文不为空。它包含五个处于READ_ONLY状态和null已加载状态的条目。

持久性上下文内容:

Total number of managed entities: 5

EntityKey[com.bookstore.entity.Author#1]:
    Author{id=1, name=Mark Janel, genre=Anthology, age=23}
EntityKey[com.bookstore.entity.Author#2]:
    Author{id=2, name=Olivia Goy, genre=Horror, age=43}
EntityKey[com.bookstore.entity.Author#3]:
    Author{id=3, name=Quartis Young, genre=Anthology, age=51}
EntityKey[com.bookstore.entity.Author#4]:
    Author{id=4, name=Joana Nimar, genre=History, age=34}
EntityKey[com.bookstore.entity.Author#5]:
    Author{id=5, name=Marin Kyrab, genre=History, age=33}

这一次,与只读实体的情况一样,数据被加载到持久性上下文中。但是,这一次,Spring 还必须创建AuthorDto列表。

提示一下,将结果集提取为List<Object[]>而不是List<AuthorDto>会产生相同的行为。

使用带有显式列列表和@Query 的 JPQL

您可以通过显式列出要获取的列来使用 JPQL 和@Query,如下所示:

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

    @Query("SELECT a.id AS id, a.age AS age, a.name AS name,
                   a.genre AS genre FROM Author a")
    List<AuthorDto> fetchAsDtoColumns();
}

被触发的 SQL 是高效的,并且非常明显:

SELECT
  author0_.id AS col_0_0_,
  author0_.age AS col_1_0_,
  author0_.name AS col_2_0_,
  author0_.genre AS col_3_0_
FROM author author0_

此外,持久性上下文保持不变。持久性上下文内容:

Total number of managed entities

: 0

这种方法非常有效。如果你用的是@Query和 JPQL,那么注意 JPQL 是怎么写的。显式列出要提取的列消除了在持久性上下文中加载数据所导致的性能损失。

提示一下,将结果集提取为List<Object[]>而不是List<AuthorDto>会产生相同的行为。

使用原生查询和@Query

您可以使用@Query和本地查询,如下所示:

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

    @Query(value = "SELECT id, age, name, genre FROM author",
           nativeQuery = true)
    List<AuthorDto> fetchAsDtoNative();
}

作为原生查询,触发的 SQL 显而易见:

SELECT
  id,
  age,
  name,
  genre
FROM author

持久性上下文保持不变。持久性上下文内容:

Total number of managed entities: 0

正如你在图 3-9 中看到的,这种方法比其他方法效率低。

图 3-9 显示了针对 100、500 和 1,000 名作者的这些方法之间的直接比较的时间-性能趋势图。看起来具有显式列列表的 JPQL 和查询构建器机制是最快的方法。

img/487471_1_En_3_Fig9_HTML.jpg

图 3-9

获取实体的所有基本属性

图 3-9 中显示的时间-性能趋势是在具有以下特征的 Windows 7 机器上针对 MySQL 获得的:英特尔 i7、2.10GHz 和 6GB RAM。应用和 MySQL 运行在同一台机器上。

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

第 31 项:如何通过构造函数表达式获取 DTO

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

目标是只获取具有相同genre的所有作者的nameage。这一次,应用依赖于带有构造函数和参数的 DTO。

第一步包括编写 DTO 类。这个类包含映射实体属性的实例变量,这些属性应该从数据库中获取,一个带有初始化这些实例变量的参数的构造函数,以及特定的 getters(不需要 setters)。下面的AuthorDto适用于取出nameage:

public class AuthorDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String name;
    private final int age;

    public AuthorDto(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

第二步包括编写一个典型的 Spring 存储库。通过 Spring 数据查询构建器机制生成所需的 SQL,并将结果集映射到List<AuthorDto>:

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

    List<AuthorDto> findByGenre(String genre);
}

调用findByGenre()将触发下面的 SQL:

SELECT
   author0_.name AS col_0_0_,
   author0_.age AS col_1_0_
FROM
   author author0_
WHERE
   author0_.genre = ?

显示结果非常简单:

List<AuthorDto> authors =...;
for (AuthorDto author : authors) {
    System.out.println("Author name: " + author.getName()
                             + " | Age: " + author.getAge());
}

下面是一个可能的输出:

Author name: Mark Janel | Age: 23
Author name: Quartis Young | Age: 51
Author name: Alicia Tom | Age: 38
...

GitHub 30 上有源代码。

Spring 数据查询构建器机制很棒,但是它有一些限制。如果这种机制不是首选的或者根本不适用,那么也可以使用 JPQL。在 JPQL 中,可以在SELECT子句中使用构造函数来返回非实体 Java 对象的实例——这被称为构造函数表达式:

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

    @Query(value="SELECT new com.bookstore.dto.AuthorDto(a.name, a.age)
                 FROM Author a")
    List<AuthorDto> fetchAuthors();
}

Hibernate 6 将支持与其他选择表达式混合的构造函数表达式(HHH-9877 31 )。关于 Hibernate 6 的更多细节,请参见附录 K.

调用fetchAuthors()将触发下面的 SQL:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_
FROM author author0_

显示结果非常简单:

List<AuthorDto> authors =...;

for (AuthorDto author : authors) {
    System.out.println("Author name: " + author.getName()
                             + " | Age: " + author.getAge());
}

可能的输出是:

Author name: Mark Janel | Age: 23
Author name: Olivia Goy | Age: 43
Author name: Quartis Young | Age: 51
...

GitHub 32 上有源代码。

如果(出于任何原因)需要通过EntityManager直接完成目标,请遵循以下示例:

Query query = entityManager.createQuery

(
    "SELECT new com.bookstore.dto.AuthorDto(a.name, a.age)
     FROM Author a", AuthorDto.class);

List<AuthorDto> authors = query.getResultList();

第 32 条:为什么你应该避免通过构造函数表达式在 DTO 获取实体

考虑两个实体,AuthorBook。它们之间没有物化的关联,但是两个实体共享一个名为genre的属性。见图 3-10 。

img/487471_1_En_3_Fig10_HTML.jpg

图 3-10

没有物化关联

目标是使用该属性连接对应于AuthorBook的表,并在 DTO 中获取结果。结果应该包含Author实体,并且只包含来自Booktitle属性。

我们已经在第 26 项“如何在弹簧投影中添加实体”中解决了这个问题。然而,这种情况可以通过 DTO 和构造函数表达式来解决。然而,所涉及的性能损失是一个明确的信号,表明应该避免这种方法。

考虑与构造函数表达式一起使用的经典 DTO 实现:

public class BookstoreDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private final Author author;
    private final String title;

    public BookstoreDto(Author author, String title) {
        this.author = author;
        this.title = title;
    }

    public Author getAuthor() {
        return author;
    }

    public String getTitle() {
        return title;
    }
}

用于填充此 d to 的 JPQL 是在以下存储库中编写的:

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

    @Query("SELECT new com.bookstore.dto.BookstoreDto(a, b.title)"
             + "FROM Author a JOIN Book b ON a.genre=b.genre ORDER BY a.id")
    List<BookstoreDto> fetchAll();
}

调用fetchAll()方法显示数据不能在单个SELECT中提取。每个作者都需要一个副手SELECT。因此,很容易出现 N+1 问题:

SELECT
  author0_.id AS col_0_0_,
  book1_.title AS col_1_0_
FROM author author0_
INNER JOIN book book1_
  ON (author0_.genre = book1_.genre)
ORDER BY author0_.id

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 = ?

GitHub 33 上有源代码。

这种方法不能在单个SELECT中获取数据,并且倾向于 N+1。使用 Spring projections,JPA Tuple,甚至 Hibernate 特有的ResultTransformer都是更好的方法。这些方法将在单个SELECT中获取数据。

尽管 Hibernate 5.3.9.Final 仍然是这样的,但是未来的 Hibernate 版本(很可能是 Hibernate 6.0)将会解决这个限制。

第 33 项:如何通过 JPA 元组获取 DTO

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

目标是只获取所有作者的nameage。这一次,应用依赖于 DTO 和 JPA,javax.persistence.Tuple。本质上,Tuple不需要 DTO 类,是一种比像Object[]那样获取数据更方便的方法,因为:

  • Tuple保留由查询填写的属性的别名(例如,从AS name,元组保留name)。使用Object[],别名信息丢失。

  • Tuple自动转换值。

  • TupleElement支持 Java 泛型,所以它比Object s 提供了更多的类型安全性。

基于这三点,我们可以说Tuple是处理标量投影的最好方法之一。它与 JPQL、Criteria API 和原生 SQL 一起工作。

第一步包括编写一个典型的 Spring 存储库,并将获取的数据映射到一个List<Tuple>中。触发的 SQL 可以通过 JPQL 或者原生 SQL 来表达(从 Hibernate ORM 5.2.11 开始)。查看基于 JPQL 的存储库:

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

    @Query(value = "SELECT a.name AS name, a.age AS age FROM Author a")
    List<Tuple> fetchAuthors();
}

调用fetchAuthors()方法将触发下面的 SQL:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_
FROM author author0_

这是基于 SQL 的本地存储库:

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

    @Query(value = "SELECT name, age FROM author",
           nativeQuery = true)
    List<Tuple> fetchAuthors();
}

调用fetchAuthors()方法将触发下面的 SQL:

SELECT
  name, age
FROM author

Tuple与 Spring 数据查询构建器机制相结合将会产生获取实体所有属性的 SQL 语句。

您可以通过一套专用的方法访问映射在Tuple中的数据。其中一个是Object get(String alias),这里的alias是某个属性的别名。例如,您可以按如下方式显示提取的名称和年龄(别名和属性名称在这里是相同的,但这不是必需的):

List<Tuple> authors = ...;
for (Tuple author : authors) {
    System.out.println("Author name: " + author.get("name")
                             + " | Age: " + author.get("age"));
}

可能的输出是:

Author name: Mark Janel | Age: 23
Author name: Olivia Goy | Age: 43
Author name: Quartis Young | Age: 51
...

此外,您可以检查值的类型:

// true
System.out.println(author.get("name") instanceof String);

// true
System.out.println(author.get("age") instanceof Integer);

GitHub 34 上有使用 JPQL 的源代码。

GitHub 35 上有使用原生 SQL 的源代码。

如果(出于任何原因)目标必须通过EntityManager直接完成,那么请遵循以下示例:

// using native SQL
Query query = entityManager.createNativeQuery(
    "SELECT name, age FROM author", Tuple.class);
List<Tuple> authors = query.getResultList();

// using JPQL
TypedQuery<Tuple> query = entityManager.createQuery(
    "SELECT a.name AS name, a.age AS age FROM Author a", Tuple.class);
List<Tuple> authors = query.getResultList();

标准 API 提供了CriteriaQuery<Tuple> createTupleQuery()

第 34 项:如何通过@SqlResultSetMapping 和@NamedNativeQuery 获取 DTO

如果您不熟悉在 Spring Boot 应用中使用命名(本地)查询,那么我建议您推迟阅读本节,直到您阅读了 Item 127

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

JPA @SqlResultSetMapping@NamedNativeQuery是一个组合,适用于标量(ColumnResult)、构造函数(ConstructorResult)和实体(EntityResult)映射。

标量映射

通过ColumnResult,您可以将任何列映射到标量结果类型。例如,让我们将name列映射如下:

@SqlResultSetMapping(
    name = "AuthorsNameMapping",
    columns = {
        @ColumnResult(name = "name")
    }
)
@NamedNativeQuery(
    name = "Author.fetchName",
    query = "SELECT name FROM author",
    resultSetMapping = "AuthorsNameMapping"
)
@Entity
public class Author implements Serializable {
...
}

Spring 存储库使用@Query注释来说明这是一个原生查询:

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

    @Query(nativeQuery = true)
    List<String> fetchName();

}

构造函数映射

这一次,目标是只获取所有作者的nameage。所以,你需要通过@SqlResultSetMapping@NamedNativeQuery取一个 d to,我们就靠ConstructorResult了。这对于不能使用构造函数表达式的本地查询尤其有用。

第一步是用相应的@SqlResultSetMapping@NamedNativeQuery来装饰Author实体,以获取名为AuthorDto的 DTO 类中的nameage:

@NamedNativeQuery(
    name = "Author.fetchNameAndAge",
    query = "SELECT name, age FROM author",
    resultSetMapping = "AuthorDtoMapping"
)
@SqlResultSetMapping(
    name = "AuthorDtoMapping",
    classes = @ConstructorResult(
        targetClass = AuthorDto.class,
        columns = {
            @ColumnResult(name = "name"),
            @ColumnResult(name = "age")
        }
    )
)
@Entity
public class Author implements Serializable {
    ...
}

AuthorDto是映射nameage的简单类,如下所示:

public class AuthorDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String name;
    private final int age;

    public AuthorDto(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

Spring 存储库使用@Query注释来说明这是一个原生查询:

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

    @Query(nativeQuery = true)
    List<AuthorDto> fetchNameAndAge();
}

调用fetchNameAndAge()将触发下面的 SQL(这是@NamedNativeQuery中提供的原生 SQL):

SELECT
  name,
  age
FROM author

GitHub 36 上有源代码。如果你不想使用{ EntityName }.{ RepositoryMethodName }约定,而更喜欢@Query(name="..."),那么就来看看这个应用 37 。此外,您更喜欢基于orm.xml的 XML 方法,这个应用 38 就是为您准备的。

如果(出于任何原因)目标必须通过EntityManager直接完成,而不需要@NamedNativeQuery,那么考虑这个 39

实体映射

您可以通过EntityResult获取单个实体或多个实体。GitHub 40 上有完整的启动应用。或者,如果你不想依赖于{EntityName}.{RepositoryMethodName}约定,而更喜欢@Query(name="..."),那么就来看看这个应用 41

第 35 项:如何通过 ResultTransformer 获取 DTO

Hibernate 的结果转换器是定制结果集映射的最强大的机制之一。结果转换器允许您以任何喜欢的方式转换结果集。

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

目标是只获取所有作者的nameage。这一次,应用依赖于 DTO 和 Hibernate 特有的ResultTransformer。这个接口是 Hibernate 特有的将查询结果转换成实际的应用可见的查询结果列表的方式。它适用于 JPQL 和本地查询,是一个非常强大的特性。

第一步包括定义 DTO 类。可以在有构造函数但没有设置器的 DTO 中,或者在没有构造函数但有设置器的 DTO 中获取数据。在带有构造函数但没有设置函数的 DTO 中获取nameage需要一个 DTO,如下所示:

public class AuthorDtoNoSetters implements Serializable {

    private static final long serialVersionUID = 1L;

    private final String name;
    private final int age;

    public AuthorDtoNoSetters(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

}

此外,应用使用AliasToBeanConstructorResultTransformer。这对这种 DTO 是有用的。您可以编写一个 JPQL 查询来通过EntityManager#createQuery()unwrap(org.hibernate.query.Query.class)方法获取nameage属性,如下所示:

@Repository
public class Dao implements AuthorDao {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional(readOnly = true)
    public List<AuthorDtoNoSetters> fetchAuthorsNoSetters() {

        Query query = entityManager
            .createQuery("SELECT a.name as name, a.age as age FROM Author a")
            .unwrap(org.hibernate.query.Query.class)
            .setResultTransformer(
                new AliasToBeanConstructorResultTransformer(
                       AuthorDtoNoSetters.class.getConstructors()[0]
                )

            );

        List<AuthorDtoNoSetters> authors = query.getResultList();

        return authors;
    }
}

可以在没有构造函数也有设置器的 DTO 中获取数据。这样的 DTO 可以如下:

public class AuthorDtoWithSetters implements Serializable {

    private static final long serialVersionUID = 1L;

    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

这一次,应用依赖于Transformers.aliasToBean()。获取nameage属性的 JPQL 查询使用EntityManager#createQuery()unwrap(org.hibernate.query.Query.class)方法,如下所示:

@Repository
public class Dao implements AuthorDao {

    PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional(readOnly = true)
    public List<AuthorDtoWithSetters> fetchAuthorsWithSetters() {
        Query query = entityManager
            .createQuery("SELECT a.name as name, a.age as age FROM Author a")
            .unwrap(org.hibernate.query.Query.class)
            .setResultTransformer(
                Transformers.aliasToBean(AuthorDtoWithSetters.class)
        );

    List<AuthorDtoWithSetters> authors = query.getResultList();

    return authors;
    }
}

调用fetchAuthorsNoSetters()fetchAuthorsWithSetters()将触发下一条 SQL:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_
FROM author author0_

因为两种 DTO 都有 getters,所以对获取数据的访问非常简单。

GitHub 42 上有源代码。

除了 JPQL,也可以使用原生 SQL 查询。在这种情况下,使用EntityManager.createNativeQuery()代替EntityManager.createQuery()unwrap(org.hibernate.query.NativeQuery.class)。GitHub 43 上有完整的例子。

从 Hibernate 5.2 开始,ResultTransformer就被弃用了,但是直到有了替代版本(在 Hibernate 6.0 中),它还可以使用(进一步阅读44T7)。ResultTransformer正在被拆分为TupleTransformerResultListTransformer(HHH-1110445T11)。关于 Hibernate 6 的更多细节,请看附录 K 。不过不用担心,迁移会相当顺利的。

第 36 项:如何通过自定义的 ResultTransformer 获取 DTO

如果你不熟悉 Hibernate 特有的ResultTransformer,那么在继续之前考虑一下第 35 项

有时候你需要一个自定义ResultTransformer来获得想要的 DTO。考虑双向懒惰@OneToMany关联中涉及的Author(带有idnamegenreagebooks)和Book(带有idtitle,isbn)实体。您想要获取每个作者的idnameage,包括他们相关书籍的idtitle

最直观的 DTO 将是这样写的一个类:

public class AuthorDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long authorId;
    private String name;
    private int age;

    private List<BookDto> books = new ArrayList<>();

    // constructor, getter, setters, etc omitted for brevity
}

如您所见,除了 ID、姓名和年龄,这个 DTO 还声明了一个List<BookDto>BookDto将图书的 ID 和标题映射如下:

public class BookDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long bookId;
    private String title;

    // constructor, getter, setters, etc omitted for brevity
}

此外,SQL JOIN可以帮助您获取所需的结果集:

@Repository
public class Dao implements AuthorDao

{

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional(readOnly = true)
    public List<AuthorDto> fetchAuthorWithBook() {

        Query query = entityManager
            .createNativeQuery(
                "SELECT a.id AS author_id, a.name AS name, a.age AS age, "
                + "b.id AS book_id, b.title AS title "
                + "FROM author a JOIN book b ON a.id=b.author_id")
            .unwrap(org.hibernate.query.NativeQuery.class)
            .setResultTransformer(new AuthorBookTransformer());

        List<AuthorDto> authors = query.getResultList();

        return authors;
    }
}

试图将结果集映射到AuthorDto是无法通过内置的ResultTransformer实现的。您需要将结果集从Object[]转换为List<AuthorDto>,为此,您需要AuthorBookTransformer,它代表了ResultTransformer接口的一个实现。这个接口定义了两个方法— transformTuple()transformList()transformTuple()允许您转换元组,元组是构成查询结果的每一行的元素。transformList()方法允许您从整体上对查询结果进行转换。

从 Hibernate 5.2 开始,ResultTransformer被弃用。直到有替代品可用(在 Hibernate 6.0 中),它才能被使用(进一步阅读 46 )。关于 Hibernate 6 的更多细节,请查看附录 K 。

您需要覆盖transformTuple()来获得查询结果的每一行所需的转换:

public class AuthorBookTransformer implements ResultTransformer {

    private Map<Long, AuthorDto> authorsDtoMap = new HashMap<>();

    @Override
    public Object transformTuple(Object[] os, String[] strings) {

        Long authorId = ((Number) os[0]).longValue();
        AuthorDto authorDto = authorsDtoMap.get(authorId);

        if (authorDto == null) {
            authorDto = new AuthorDto();
            authorDto.setId(((Number) os[0]).longValue());
            authorDto.setName((String) os[1]);
            authorDto.setAge((int) os[2]);
        }

        BookDto bookDto = new BookDto();
        bookDto.setId(((Number) os[3]).longValue());
        bookDto.setTitle((String) os[4]);

        authorDto.addBook(bookDto);

        authorsDtoMap.putIfAbsent(authorDto.getId(), authorDto);

        return authorDto;
    }

    @Override
    public List<AuthorDto> transformList(List list) {
        return new ArrayList<>(authorsDtoMap.values());
    }
}

请随意进一步优化这个实现。现在,让我们编写一个 REST 控制器端点,如下所示:

@GetMapping("/authorWithBook")
public List<AuthorDto> fetchAuthorWithBook() {
    return bookstoreService.fetchAuthorWithBook();
}

访问localhost:8080/authorWithBook返回以下 JSON:

[
   {
      "name":"Mark Janel",
      "age":23,
      "books":[
         {
            "title":"The Beatles Anthology",
            "id":3
         },
         {
            "title":"Anthology Of An Year",
            "id":7
         },
         {
            "title":"Anthology From A to Z",
            "id":8
         },
         {
            "title":"Past Anthology",
            "id":9
         }
      ],
      "id":1
   },
   {
      "name":"Olivia Goy",
      "age":43,
      "books":[
         {
            "title":"Carrie",
            "id":4
         },
         {
            "title":"Horror Train",
            "id":6
         }
      ],
      "id":2
   },
   {
      "name":"Joana Nimar",
      "age":34,
      "books":[
         {
            "title":"A History of Ancient Prague",
            "id":1
         },
         {
            "title":"A People's History",
            "id":2
         },
         {
            "title":"History Today",
            "id":5
         }
      ],
      "id":4
   }
]

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

第 37 项:如何通过@Subselect 将实体映射到查询

只有在评估了基于 DTO、DTO +额外查询的潜在解决方案,或者将数据库视图映射到实体之后,才考虑使用@Subselect

这一项讨论了通过 Hibernate 特有的@Subselect将实体映射到查询。考虑双向惰性@OneToMany关联中的这两个实体,如下所示:

@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<>();
    ...
}

@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;
    ...
}

An Author编写了几个Book,其思想是编写一个只读查询从Author获取一些字段(例如idnamegenre),但是也有可能调用getBooks()并以一种懒惰的方式获取List<Book>。如您所知,不能使用经典的 DTO,因为这样的 DTO 是不受管理的,我们不能浏览关联(这不支持任何到其他实体的受管理的关联)。

Hibernate 特有的@Subselect为这个问题提供了一个解决方案。通过@Subselect,应用可以将一个不可变的只读实体映射到给定的 SQL SELECT。通过这个实体,应用可以按需获取关联(您可以随意导航关联)。接下来的步骤是:

  • 定义一个新实体,它只包含来自Author的所需字段(包括与Book的关联也非常重要)。

  • 对于所有这些字段,只定义 getters。

  • 将此实体标记为@Immutable,因为不允许写操作。

  • 使用@Synchronize刷新所用实体的挂起状态转换。Hibernate 将在获取AuthorDto实体之前执行同步。

  • 使用@Subselect编写所需的查询(将一个实体映射到一个获取idnamegenre,但不获取books的 SQL 查询)。

将这些步骤粘合到代码中会产生以下实体:

@Entity
@Subselect(
    "SELECT a.id AS id, a.name AS name, a.genre AS genre FROM Author a")
@Synchronize({"author", "book"})
@Immutable
public class AuthorSummary implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    private String name;
    private String genre;

    @OneToMany(mappedBy = "author")
    private Set<Book> books = new HashSet<>();

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getGenre() {
        return genre;
    }

    public Set<Book> getBooks() {
        return books;
    }
}

此外,为AuthorSummary编写一个经典的 Spring 存储库:

@Repository
public interface AuthorDtoRepository
       extends JpaRepository<AuthorSummary, Long> {
}

服务方法可以通过 ID 获取作者,如果获取的作者的流派与给定的流派相同,它也可以通过显式调用getBooks()来获取书籍:

@Transactional(readOnly = true)
public void fetchAuthorWithBooksById(long id, String genre) {

    AuthorSummary author = authorSummaryRepository
        .findById(id).orElseThrow();

    System.out.println("Author: " + author.getName());

    if (author.getGenre().equals(genre)) {
        // lazy loading the books of this author
        Set<Book> books = author.getBooks();
        books.forEach((b) -> System.out.println("Book: "
            + b.getTitle() + "(" + b.getIsbn() + ")"));
    }
}

可以考虑取 ID 为 4 且流派为历史的作者。图 3-11 显示提取的行(第一个SELECT将提取作者idnamegenre;次级SELECT将获取该作者的书籍)。

img/487471_1_En_3_Fig11_HTML.jpg

图 3-11

获取 ID 为 4 的作者和历史流派

触发获取这些数据的 SQL 语句是(这次,Hibernate 使用提供的 SQL 语句作为FROM子句中的子SELECT),而不是数据库表名:

SELECT
  authordto0_.id AS id1_0_,
  authordto0_.genre AS genre2_0_,
  authordto0_.name AS name3_0_
FROM (SELECT
  a.id AS id,
  a.name AS name,
  a.genre AS genre
FROM Author a) authordto0_

SELECT
  books0_.author_id AS author_i4_1_0_,
  books0_.id AS id1_1_0_,
  books0_.id AS id1_1_1_,
  books0_.author_id AS author_i4_1_1_,
  books0_.isbn AS isbn2_1_1_,
  books0_.title AS title3_1_1_
FROM book books0_
WHERE books0_.author_id = ?

GitHub 48 上有源代码。

第 38 项:如何通过 Blaze-Persistence 实体视图获取 DTO

假设应用包含下面的Author实体。该实体映射一个作者简档:

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

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

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

    // getters and setters omitted for brevity
}

目标是只获取所有作者的nameage。这一次,应用依赖于 Blaze Persistence49实体视图。Blaze Persistence 是一个开源项目,旨在为 JPA 提供者提供丰富的标准 API。由于在 Spring Boot 之外,它必须作为一个依赖项添加到应用中。例如,通过 Maven,您可以向pom.xml添加以下依赖项:

<dependency>
    <groupId>com.blazebit</groupId>
    <artifactId>blaze-persistence-integration-entity-view-spring</artifactId>
    <version>${blaze-persistence.version}</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.blazebit</groupId>
    <artifactId>blaze-persistence-integration-spring-data-2.0</artifactId>
    <version>${blaze-persistence.version}</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.blazebit</groupId>
    <artifactId>blaze-persistence-jpa-criteria-api</artifactId>
    <version>${blaze-persistence.version}</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>com.blazebit</groupId>
    <artifactId>blaze-persistence-integration-hibernate-5.2</artifactId>
    <version>${blaze-persistence.version}</version>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>com.blazebit</groupId>
    <artifactId>blaze-persistence-jpa-criteria-impl</artifactId>
    <version>${blaze-persistence.version}</version>
    <scope>runtime</scope>
</dependency>

此外,配置 Blaze-Persistence、CriteriaBuilderFactoryEntityViewManager。这可以通过传统的弹簧配置类和@Bean来实现,如下所示:

@Configuration
@EnableEntityViews("com.bookstore")
@EnableJpaRepositories(
    basePackages = "com.bookstore",
    repositoryFactoryBeanClass = BlazePersistenceRepositoryFactoryBean.class)
public class BlazeConfiguration {

    private final LocalContainerEntityManagerFactoryBean
        localContainerEntityManagerFactoryBean;

    public BlazeConfiguration(LocalContainerEntityManagerFactoryBean
                                localContainerEntityManagerFactoryBean) {
        this.localContainerEntityManagerFactoryBean =
            localContainerEntityManagerFactoryBean;
    }

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
    @Lazy(false)
    public CriteriaBuilderFactory createCriteriaBuilderFactory() {
        CriteriaBuilderConfiguration config = Criteria.getDefault();

        return config.createCriteriaBuilderFactory(
            localContainerEntityManagerFactoryBean.getObject());
    }

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
    @Lazy(false)
    public EntityViewManager createEntityViewManager(
        CriteriaBuilderFactory cbf, EntityViewConfiguration
                                     entityViewConfiguration) {

        return entityViewConfiguration.createEntityViewManager(cbf);
    }
}

所有的设置都到位了。是时候利用 Blaze Persistence 了。应用应该只从数据库中获取作者的姓名和年龄。因此,是时候通过一个接口以 Blaze-Persistence 的方式编写一个 DTO,或者更准确地说,一个实体视图。这里的关键在于用@EntityView(Author.class)注释视图:

@EntityView(Author.class)
public interface AuthorView {

    public String getName();
    public int getAge();
}

此外,通过扩展EntityViewRepository编写一个以 Spring 为中心的存储库(这是一个 Blaze 持久性接口):

@Repository
@Transactional(readOnly = true)
public interface AuthorViewRepository
    extends EntityViewRepository<AuthorView, Long> {
}

EntityViewRepository接口是一个基本接口,它继承了最常用的存储库方法。基本上,它可以用作任何其他 Spring 数据存储库。例如,您可以调用findAll()来获取AuthorView中的所有作者,如下所示:

@Service
public class BookstoreService {

    private final AuthorViewRepository authorViewRepository;

    public BookstoreService(AuthorViewRepository authorViewRepository) {
        this.authorViewRepository = authorViewRepository;
    }

    public Iterable<AuthorView> fetchAuthors() {
        return authorViewRepository.findAll();
    }
}

调用fetchAuthors()方法将触发下面的 SQL:

SELECT
  author0_.age AS col_0_0_,
  author0_.name AS col_1_0_
FROM author author0_

GitHub 50 上有源代码。

第 39 项:如何在一次选择中有效地提取父项和关联

假设下面两个实体AuthorBook处于双向惰性@OneToMany关联中(也可以是另一种类型的关联,也可以是单向的):

@Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    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<>();
    ...
}

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    private String title;
    private String isbn;

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

这是一个从两个方向看都很懒的联想。加载一个Author不会加载它的Book,反之亦然(加载一个Book不会加载它的Author)。这种行为在某些情况下可能没问题,在其他情况下可能没问题,这取决于当前功能的需求。

这一次,目标是执行以下两个查询:

  • 按姓名获取作者,包括他们的书

  • 按国际标准书号取书,包括作者

作者和书籍之间有一个松散的关联,这个目标可以在两个 SQL SELECT中完成。在一个SELECT中获取作者并调用getBooks()将触发第二个SELECT来获取书籍。或者,在SELECT中获取一本书并调用getAuthor()将触发第二个SELECT来获取作者。这种方法突出了至少两个缺点:

  • 应用触发两个SELECT而不是一个。

  • 惰性抓取(第二个SELECT)必须发生在活动的 Hibernate 会话中,以避免LazyInitializationException(如果应用在 Hibernate 会话之外调用author.getBooks()book.getAuthor(),就会出现这个异常)。

显然,在这种情况下,最好在一个SELECT中获取作者和图书数据,而不是两个。但是,应用不能使用 SQL JOIN + DTO,因为它计划修改这些实体。因此,它们应该由 Hibernate 来管理。使用 SQL JOIN获取这些实体也不是一个实用的选择(为此,考虑 Item 40 )。一种简单的方法是在实体级别将关联从LAZY切换到EAGER。这个可以,但是不要这么做!根据经验,使用LAZY关联,并通过JOIN FETCH(如果应用计划修改提取的实体)或通过JOIN + DTO(如果提取的数据是只读的)在查询级别提取这些关联。在这种情况下,JOIN FETCH是正确的选择。

JOIN FETCH是 JPA 特有的,它允许使用一个SELECT来初始化值的关联(对于集合尤其有用)以及它们的父对象。在 Spring 风格中,这个目标可以通过两个经典的存储库和 JPQL 来实现:

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

    @Query(value = "SELECT a FROM Author a JOIN FETCH a.books
                    WHERE a.name = ?1")
    Author fetchAuthorWithBooksByName(String name);
}

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

    @Query(value = "SELECT b FROM Book b JOIN FETCH b.author
                    WHERE b.isbn = ?1")
    Book fetchBookWithAuthorByIsbn(String isbn);
}

调用fetchAuthorWithBooksByName()将触发下面的 SQL(将Author和它们的Book加载到一个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_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.name = ?

调用fetchBookWithAuthorByIsbn()将触发下面的 SQL(将Book和它的Author加载在一个SELECT中):

SELECT
  book0_.id AS id1_1_0_,
  author1_.id AS id1_0_1_,
  book0_.author_id AS author_i4_1_0_,
  book0_.isbn AS isbn2_1_0_,
  book0_.title AS title3_1_0_,
  author1_.age AS age2_0_1_,
  author1_.genre AS genre3_0_1_,
  author1_.name AS name4_0_1_
FROM book book0_
INNER JOIN author author1_
  ON book0_.author_id = author1_.id
WHERE book0_.isbn = ?

特别是对于@OneToMany@ManyToMany关联,最好在实体级将关联设置为LAZY,并通过JOIN FETCH(如果应用计划修改获取的实体)或通过JOIN + DTO(如果获取的数据是只读的)在查询级获取该关联。不能基于查询覆盖急切获取策略。只有惰性抓取策略可以基于查询被覆盖。

连接表可能会产生笛卡尔乘积(例如,CROSS JOIN,其中第一个表中的每一行都与第二个表中的每一行匹配)或大型结果集。另一方面,FetchType.LAZY导致二次查询(N+1)。如果有 100 个作者,每个人都写了 5 本书,那么笛卡尔积查询将获取 100 x 5 = 500 行。另一方面,依靠FetchType.LAZY会引起 100 次二次查询(每个作者一次二次查询)。提取多个一对多或多对多关联可能会导致复杂的笛卡尔积或大量的二级查询。有一个大的笛卡尔积比大量的数据库往返要好。然而,如果你可以用几个查询来避免一个大的笛卡尔积,那么就使用这些查询。

GitHub 51 上有源代码。

第 40 项:如何决定连接和连接获取

通常,JOINJOIN FETCH在应用具有惰性关联,但必须急切获取某些数据时发挥作用。在实体级依赖FetchType.EAGER的是代码气味。假设双向惰性@OneToMany关联中涉及到众所周知的AuthorBook实体:

 @Entity
public class Author implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    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<>();
    ...
}

@Entity
public class Book implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    private String title;
    private String isbn;

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

考虑图 3-12 中所示的样本数据。

img/487471_1_En_3_Fig12_HTML.jpg

图 3-12

数据快照

目标是获取以下数据作为实体:

  • 所有比给定价格更贵的Author及其Book

  • 所有的Book和它们的Author

获取所有比给定价格更贵的作者及其书籍

为了满足第一个查询(获取所有比给定价格更贵的Author和它们的Book),您可以编写一个 Spring 存储库AuthorRepository,并添加一个JOIN和一个JOIN FETCH查询来获取相同的数据:

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

    // INNER JOIN
    @Query(value = "SELECT a FROM Author a INNER JOIN a.books b
                    WHERE b.price > ?1")
    List<Author> fetchAuthorsBooksByPriceInnerJoin(int price);

    // JOIN FETCH
    @Query(value = "SELECT a FROM Author a JOIN FETCH a.books b
                    WHERE b.price > ?1")
    List<Author> fetchAuthorsBooksByPriceJoinFetch(int price);
}

您可以调用这些存储库方法,并将获取的数据显示到控制台,如下所示:

public void fetchAuthorsBooksByPriceJoinFetch() {

    List<Author> authors =
            authorRepository.fetchAuthorsBooksByPriceJoinFetch(40);

    authors.forEach((e) -> System.out.println("Author name: "
        + e.getName() + ", books: " + e.getBooks()));
}

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

    List<Author> authors =
            authorRepository.fetchAuthorsBooksByPriceInnerJoin(40);

    authors.forEach((e) -> System.out.println("Author name: "
        + e.getName() + ", books: " + e.getBooks()))

;
}

JOIN FETCH 将如何操作

JOIN FETCH是 JPA 特有的,它允许使用一个SELECT来初始化关联及其父对象。您很快就会看到,这在获取关联集合时特别有用。这意味着调用fetchAuthorsBooksByPriceJoinFetch()将触发单个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_i5_1_1_,
  books1_.isbn AS isbn2_1_1_,
  books1_.price AS price3_1_1_,
  books1_.title AS title4_1_1_,
  books1_.author_id AS author_i5_1_0__,
  books1_.id AS id1_1_0__
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE books1_.price > ?

对给定价格为 40 美元的数据样本运行此 SQL 将获取以下数据(显示作者姓名和书籍):

Author name: Joana Nimar,
      books: [Book{id=2, title=A People's History, isbn=002-JN, price=41}]

这个看起来没错!数据库中有一本比 40 美元还贵的书,它的作者是 ?? 的乔安娜·尼玛尔。

JOIN 将如何行动

另一方面,JOIN不允许使用单个SELECT来初始化关联的集合及其父对象。这意味着调用fetchAuthorsBooksByPriceInnerJoin()将导致下面的SELECT(SQL 显示没有书被加载):

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
INNER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE books1_.price > ?

对数据样本运行这个 SQL 将获取一个作者( Joana Nimar ),这是正确的。试图通过getBooks()显示乔安娜·尼玛尔写的书将会触发一个额外的SELECT,如下所示:

SELECT
  books0_.author_id AS author_i5_1_0_,
  books0_.id AS id1_1_0_,
  books0_.id AS id1_1_1_,
  books0_.author_id AS author_i5_1_1_,
  books0_.isbn AS isbn2_1_1_,
  books0_.price AS price3_1_1_,
  books0_.title AS title4_1_1_
FROM book books0_
WHERE books0_.author_id = ?

编写这个查询也没有帮助:

@Query(value = "SELECT a, b FROM Author a
                      INNER JOIN a.books b WHERE b.price > ?1")

显示作者姓名和获取的图书:

Author name: Joana Nimar,
    books: [
      Book{id=1, title=A History of Ancient Prague, isbn=001-JN, price=36},
      Book{id=2, title=A People's History, isbn=002-JN, price=41}
    ]

这里必须强调两件事:一个重要的缺点和一个潜在的混乱。

第一,弊端。请注意,JOIN已经在另一个SELECT中获取了图书。与JOIN FETCH相比,这可以被认为是一种性能损失,后者只需要一次SELECT,因此只需要一次数据库往返。

第二,潜在的困惑。特别注意第一个SELECTWHERE books1_.price > ?子句的解释。虽然应用只获取那些写了比 40 美元更贵的书的作者,但是当调用getBooks()时,应用获取这些作者的所有书,而不仅仅是比 40 美元更贵的书。这是正常的,因为当调用getBooks()时,WHERE子句不再存在。因此,在这种情况下,JOIN产生的结果与JOIN FETCH不同。

获取所有书籍及其作者

为了满足第二个查询(所有的Book和它们的Author),编写一个 Spring 存储库和一个BookRepository,然后添加两个JOIN和一个JOIN FETCH查询:

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

    // INNER JOIN BAD
    @Query(value = "SELECT b FROM Book b INNER JOIN b.author a")
    List<Book> fetchBooksAuthorsInnerJoinBad();

    // INNER JOIN GOOD
    @Query(value = "SELECT b, a FROM Book b INNER JOIN b.author a")
    List<Book> fetchBooksAuthorsInnerJoinGood();

    // JOIN FETCH
    @Query(value = "SELECT b FROM Book b JOIN FETCH b.author a")
    List<Book> fetchBooksAuthorsJoinFetch();
}

您可以调用这些方法并将获取的数据显示到控制台,如下所示:

public void fetchBooksAuthorsJoinFetch() {

    List<Book> books = bookRepository.fetchBooksAuthorsJoinFetch();

    books.forEach((e) -> System.out.println("Book title: " + e.getTitle()
        + ", Isbn:" + e.getIsbn() + ", author: " + e.getAuthor()));
}

@Transactional(readOnly = true)
public void fetchBooksAuthorsInnerJoinBad/Good() {

    List<Book> books = bookRepository.fetchBooksAuthorsInnerJoinBad/Good();

    books.forEach((e) -> System.out.println("Book title: " + e.getTitle()
        + ", Isbn: " + e.getIsbn() + ", author: " + e.getAuthor()))

;
}

JOIN FETCH 将如何操作

调用fetchBooksAuthorsJoinFetch()将触发如下触发的单个 SQL(在单个SELECT中获取所有作者和书籍) :

SELECT
  book0_.id AS id1_1_0_,
  author1_.id AS id1_0_1_,
  book0_.author_id AS author_i5_1_0_,
  book0_.isbn AS isbn2_1_0_,
  book0_.price AS price3_1_0_,
  book0_.title AS title4_1_0_,
  author1_.age AS age2_0_1_,
  author1_.genre AS genre3_0_1_,
  author1_.name AS name4_0_1_
FROM book book0_
INNER JOIN author author1_
  ON book0_.author_id = author1_.id

对数据示例运行此 SQL 将输出以下内容(仅显示书名、ISBN 和作者):

Book title: A History of Ancient Prague, Isbn:001-JN,
    author: Author{id=4, name=Joana Nimar, genre=History, age=34}

Book title: A People's History, Isbn:002-JN,
    author: Author{id=4, name=Joana Nimar, genre=History, age=34}

Book title: The Beatles Anthology, Isbn:001-MJ,
    author: Author{id=1, name=Mark Janel, genre=Anthology, age=23}

Book title: Carrie, Isbn:001-OG,
    author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}

一切看起来都在意料之中!有四本书,每本书都有作者。

JOIN 将如何行动

另一方面,调用fetchBooksAuthorsInnerJoinBad()将如下触发一个 SQL(该 SQL 显示没有加载作者):

SELECT
  book0_.id AS id1_1_,
  book0_.author_id AS author_i5_1_,
  book0_.isbn AS isbn2_1_,
  book0_.price AS price3_1_,
  book0_.title AS title4_1_
FROM book book0_
INNER JOIN author author1_
  ON book0_.author_id = author1_.id

返回的List<Book>包含四个Book,循环这个列表并通过getAuthor()获取每本书的作者将会触发另外三个SELECT语句。有三个SELECT陈述而不是四个,因为两本书的作者相同。因此,对于这两本书中的第二本,作者将从持久性上下文中取出。因此,该SELECT以不同的id值被触发三次:

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 = ?

显示每本书的标题、ISBN 和作者将输出以下内容:

Book title: A History of Ancient Prague, Isbn: 001-JN,
     author: Author{id=4, name=Joana Nimar, genre=History, age=34}

Book title: A People's History, Isbn: 002-JN,
     author: Author{id=4, name=Joana Nimar, genre=History, age=34}

Book title: The Beatles Anthology, Isbn: 001-MJ,
     author: Author{id=1, name=Mark Janel, genre=Anthology, age=23}

Book title: Carrie, Isbn: 001-OG,
     author: Author{id=2, name=Olivia Goy, genre=Horror, age=43}

在这种情况下,性能损失是明显的。JOIN FETCH需要一条SELECT , JOIN需要四条SELECT语句。

fetchBooksAuthorsInnerJoinGood()怎么样?这将产生与JOIN FETCH完全相同的查询和结果。这是可行的,因为提取的关联不是集合。所以,在这种情况下,你可以用JOIN或者JOIN FETCH

根据经验,只要数据应该作为实体提取(因为应用计划修改它们),就使用JOIN FETCH(不是JOIN), Hibernate 应该在SELECT子句中包含关联。这在获取关联集合时特别有用。在这种情况下,使用JOIN容易导致 N+1 的性能损失。另一方面,每当你获取只读数据时(你不打算修改它),最好依靠JOIN + DTO,而不是JOIN FETCH

请注意,虽然像SELECT a FROM Author a JOIN FETCH a.books这样的查询是正确的,但以下尝试将不起作用:

SELECT a.age as age FROM Author a JOIN FETCH a.books

原因:org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list

SELECT a FROM Author a JOIN FETCH a.books.title

原因:org.hibernate.QueryException: illegal attempt to dereference collection [author0_.id.books] with element property reference [title]

GitHub 52 上有源代码。

第 41 项:如何提取所有剩余的实体

考虑双向惰性一对多关联中涉及的众所周知的AuthorBook实体,如图 3-13 所示。

img/487471_1_En_3_Fig13_HTML.jpg

图 3-13

@OneToMany 表关系

第 39 项,很明显,在单个SELECT中获取一个实体及其惰性关联(特别是关联集合)是JOIN FETCH的完美工作。

JOIN FETCH被转换成一个INNER JOIN。因此,结果集包括语句左侧引用的实体或表中与语句右侧引用的实体或表相匹配的行。您可以通过LEFT JOIN获取普通 SQL 语句左侧引用的实体或表的所有行。LEFT JOIN不会提取同一个SELECT中的关联集合。

因此,解决方案应该结合JOIN FETCHLEFT JOIN带来的优点,并消除它们的缺点。这完全可以通过LEFT JOIN FETCH来实现,如下所示:

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

    @Query(value = "SELECT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> fetchAuthorWithBooks();
}

调用fetchAuthorWithBooks()将触发下面的 SQL(注意LEFT OUTER JOIN的存在):

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

还是那个BookRepository:

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

    @Query(value = "SELECT b FROM Book b LEFT JOIN FETCH b.author")
    // or, via JOIN
    // @Query(value = "SELECT b, a FROM Book b LEFT JOIN b.author a")
    List<Book> fetchBookWithAuthor();
}

调用fetchBookWithAuthor()将触发下面的 SQL(注意LEFT OUTER JOIN的存在):

SELECT
  book0_.id AS id1_1_0_,
  author1_.id AS id1_0_1_,
  book0_.author_id AS author_i4_1_0_,
  book0_.isbn AS isbn2_1_0_,
  book0_.title AS title3_1_0_,
  author1_.age AS age2_0_1_,
  author1_.genre AS genre3_0_1_,
  author1_.name AS name4_0_1_
FROM book book0_
LEFT OUTER JOIN author author1_
  ON book0_.author_id = author1_.id

GitHub 53 上有源代码。

第 42 项:如何从不相关的实体获取 DTO

不相关的实体是指它们之间没有显式关联的实体。例如,图 3-14 表示两个不相关的实体AuthorBook对应的表。

img/487471_1_En_3_Fig14_HTML.jpg

图 3-14

没有关系的表

但是,请注意这两个表都有name列。这是作者的name。目标是获取一个 DTO (Spring projection ),其中包含作者姓名和书名,价格等于给定值。

Hibernate 5.1 引入了不相关实体的显式连接,语法和行为类似于 SQL JOIN语句。例如,在这种情况下,以下查询很有用:

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

    @Query(value = "SELECT a.name AS name, b.title AS title "
             + "FROM Author a INNER JOIN Book b ON a.name = b.name "
             + "WHERE b.price = ?1")
    List<BookstoreDto> fetchAuthorNameBookTitleWithPrice(int price);
}

SQL 语句是:

SELECT
  author0_.name AS col_0_0_,
  book1_.title AS col_1_0_
FROM author author0_
INNER JOIN book book1_
  ON (author0_.name = book1_.name)
WHERE book1_.price = ?

GitHub 54 上有源代码。

第 43 项:如何编写连接语句

JOIN语句的简要概述应引出对三种主要连接类型的讨论:

  • INNER

  • OUTER

  • CROSS

如果两个表中都有数据,那么INNER JOIN对于获取数据非常有用。

OUTER JOIN可以是:

  • LEFT OUTER JOIN:取左侧表格中的数据

  • RIGHT OUTER JOIN:取右表中的数据

  • FULL OUTER JOIN:获取两个表中的任何一个表中的数据(可以是包含的,也可以是排他的)

  • CROSS JOIN:以万物联结万物;没有ONWHERE子句的CROSS JOIN给出笛卡尔积

在查询(JPQL/SQL)中,指定JOIN意味着INNER JOIN。指定LEFT / RIGHT / FULL JOIN表示LEFT / RIGHT / FULL OUTER JOIN

SQL JOIN语句是减轻著名的LazyInitializationException的最好方法。此外,对于只读数据,结合 SQL JOIN语句和 DTO(例如 Spring projections)是从多个表中获取数据的最佳方法。通常,SQL JOIN语句是通过维恩图来表示的(即使这可能不是最好的表示,但是非常容易理解)。SQL JOIN的文氏图如图 3-15 所示。

img/487471_1_En_3_Fig15_HTML.jpg

图 3-15

连接

使用双向懒惰@OneToMany关联中涉及的AuthorBook实体,考虑一个获取作者姓名和书名的 Spring 投影(DTO ):

public interface AuthorNameBookTitle {

    String getName();
    String getTitle();
}

内部连接

考虑到author表是表 A 而book表是表 B,通过 JPQL 表达的INNER JOIN可以写成如下:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Author a INNER JOIN a.books b")
List<AuthorNameBookTitle> findAuthorsAndBooksJpql();

或者假设book是表 A,author是表 B:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Book b INNER JOIN b.author a")
List<AuthorNameBookTitle> findBooksAndAuthorsJpql();

作为本机 SQL:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM author a INNER JOIN book b ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findAuthorsAndBooksSql();

@Query(value = "SELECT b.title AS title, a.name AS name "
       + "FROM book b INNER JOIN author a ON a.id = b.author_id",
     nativeQuery = true)
List<AuthorNameBookTitle> findBooksAndAuthorsSql();

添加一个WHERE子句可以帮助您过滤结果集。例如,让我们根据作者的类型和图书的价格来过滤结果集:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Author a INNER JOIN a.books b "
         + "WHERE a.genre = ?1 AND b.price < ?2")
List<AuthorNameBookTitle> findAuthorsAndBooksByGenreAndPriceJpql(
    String genre, int price);

在本机 SQL 中:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM author a INNER JOIN book b ON a.id = b.author_id "
         + "WHERE a.genre = ?1 AND b.price < ?2",
       nativeQuery = true)
List<AuthorNameBookTitle> findBooksAndAuthorsByGenreAndPriceSql(
    String genre, int price);

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

左连接

考虑到author表是表 A,而book表是表 B,通过 JPQL 表达的LEFT JOIN可以写成如下:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Author a LEFT JOIN a.books b")
List<AuthorNameBookTitle> findAuthorsAndBooksJpql();

或者假设book是表 A,author是表 B:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Book b LEFT JOIN b.author a")
List<AuthorNameBookTitle> findBooksAndAuthorsJpql();

作为本机 SQL:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM author a LEFT JOIN book b ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findAuthorsAndBooksSql();

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM book b LEFT JOIN author a ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findBooksAndAuthorsSql();

完整的代码可以在 GitHub 56 上找到。另外,这个 57 应用是写独占LEFT JOIN s 的一个样本。

右连接

假设author表是表 A,book表是表 b,通过 JPQL 表达的RIGHT JOIN可以写成:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Author a RIGHT JOIN a.books b")
List<AuthorNameBookTitle> findAuthorsAndBooksJpql();

或者假设book是表 A,author是表 B:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Book b RIGHT JOIN b.author a")
List<AuthorNameBookTitle> findBooksAndAuthorsJpql();

作为本机 SQL:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM author a RIGHT JOIN book b ON a.id = b.author_id",
     nativeQuery = true)
List<AuthorNameBookTitle> findAuthorsAndBooksSql();

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM book b RIGHT JOIN author a ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findBooksAndAuthorsSql();

完整的代码可以在 GitHub 58 上找到。另外,这个 59 应用是写独占RIGHT JOIN s 的样本。

交叉连接

一个CROSS JOIN没有一个ONWHERE子句,并返回笛卡尔积。让我们假设您有BookFormat实体(Format实体有一个formatType字段,表示特定的图书格式——例如平装PDFkindle 等。).这些实体之间没有关系。

考虑到book表是表 A,而format表是表 B,通过 JPQL 表达的CROSS JOIN可以写成如下:

@Query(value = "SELECT b.title AS title, f.formatType AS formatType "
        + "FROM Book b, Format f")
List<BookTitleAndFormatType> findBooksAndFormatsJpql();

或者假设format是表 A,book是表 B:

@Query(value = "SELECT b.title AS title, f.formatType AS formatType "
       + "FROM Format f, Book b")
List<BookTitleAndFormatType> findFormatsAndBooksJpql();

作为本机 SQL:

@Query(value = "SELECT b.title AS title, f.format_type AS formatType "
       + "FROM format f CROSS JOIN book b",
      nativeQuery = true)
List<BookTitleAndFormatType> findFormatsAndBooksSql();

@Query(value = "SELECT b.title AS title, f.format_type AS formatType "
       + "FROM book b CROSS JOIN format f",
       nativeQuery = true)
List<BookTitleAndFormatType> findBooksAndFormatsSql();

BookTitleAndFormatType是一个简单的弹簧突起:

public interface BookTitleAndFormatType {

    String getTitle();      

    String getFormatType();
}

注意一对一关联中的隐含 JOIN语句。这些类型的JOIN语句将执行一个CROSS JOIN,而不是如你所料的INNER JOIN。例如,考虑下面的 JPQL:

@Query(value = "SELECT b.title AS title, b.author.name
            AS name FROM Book b")
List<AuthorNameBookTitle> findBooksAndAuthorsJpql();

这种隐式的JOIN导致了带有WHERE子句的CROSS JOIN,而不是INNER JOIN:

SELECT
  book0_.title AS col_0_0_,
  author1_.name AS col_1_0_
FROM book book0_
CROSS JOIN author author1_
WHERE book0_.author_id = author1_.id

根据经验,为了避免这种情况,最好依靠明确的JOIN语句。如果你获取实体,依靠JOIN FETCH ( 第 39 项)。此外,始终检查通过 Criteria API 生成的 SQL 语句,因为它们也容易包含不需要的CROSS JOIN

完整代码可在 GitHub 60 上获得。

完全连接

MySQL 不支持FULL JOIN s。本节中的例子是在 PostgreSQL 上测试的。

假设author表是表 A,book表是表 b,通过 JPQL 表达的包含性FULL JOIN可以写成如下:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Author a FULL JOIN a.books b")
List<AuthorNameBookTitle> findAuthorsAndBooksJpql();

或者假设book是表 A,author是表 B:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM Book b FULL JOIN b.author a")
List<AuthorNameBookTitle> findBooksAndAuthorsJpql();

作为本机 SQL:

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM author a FULL JOIN book b ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findAuthorsAndBooksSql();

@Query(value = "SELECT b.title AS title, a.name AS name "
         + "FROM book b FULL JOIN author a ON a.id = b.author_id",
       nativeQuery = true)
List<AuthorNameBookTitle> findBooksAndAuthorsSql();

完整的代码可以在 GitHub 61 上找到。另外,这个 62 应用是写独占FULL JOIN s 的一个样本。

在 MySQL 中模拟完全连接

MySQL 不支持FULL JOIN s,但是有几种方法可以模拟FULL JOIN s。最好的方法是依靠UNIONUNION ALL。它们之间的区别在于,UNION会删除重复项,而UNION ALL也会返回重复项。

JPA 不支持UNION子句;因此,您需要使用原生 SQL。这个想法是通过两个外部连接的UNION来模拟一个包含的FULL JOIN,如下所示:

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

    @Query(value = "(SELECT b.title AS title, a.name AS name FROM author a "
             + "LEFT JOIN book b ON a.id = b.author_id) "
             + "UNION "
             + "(SELECT b.title AS title, a.name AS name FROM author a "
             + "RIGHT JOIN book b ON a.id = b.author_id "
             + "WHERE a.id IS NULL)",
           nativeQuery = true)
    List<AuthorNameBookTitle> findAuthorsAndBooksSql();
}

该查询使用了UNION;因此,它会删除重复项。然而,在一些合理的情况下,可能会出现重复的结果。在这种情况下,使用UNION ALL而不是UNION

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

第 44 项:如何对联接进行分页

考虑双向惰性@OneToMany关联中众所周知的AuthorBook实体。现在,让我们假设获取的结果集应该是只读的,它应该只包含给定类型的作者的姓名和年龄以及相关书籍的 ISBNs 和标题。此外,您希望获取页面中的结果集。这是一个完美的工作JOIN +预测(DTO);因此,首先按如下方式编写弹簧投影:

public interface AuthorBookDto {

    public String getName();  // of author
    public int getAge();      // of author
    public String getTitle(); // of book
    public String getIsbn();  // of book
}

此外,编写一个依赖于LEFT JOIN的 JPQL,如下所示:

@Transactional(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age,
                b.title AS title, b.isbn AS isbn
                FROM Author a LEFT JOIN a.books b WHERE a.genre = ?1")
Page<AuthorBookDto> fetchPageOfDto(String genre, Pageable pageable);

调用fetchPageOfDto()的服务方法可以编写如下:

public Page<AuthorBookDto> fetchPageOfAuthorsWithBooksDtoByGenre(
                                               int page, int size) {

    Pageable pageable = PageRequest.of(page, size,
        Sort.by(Sort.Direction.ASC, "name"));
    Page<AuthorBookDto> pageOfAuthors
        = authorRepository.fetchPageOfDto("Anthology", pageable);

    return pageOfAuthors

;
}

触发器 SQL 语句如下:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_,
  books1_.title AS col_2_0_,
  books1_.isbn AS col_3_0_
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
ORDER BY author0_.name ASC LIMIT ? ?

SELECT
  COUNT(author0_.id) AS col_0_0_
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.genre = ?

可能的结果集的 JSON 表示如下:

{
   "content":[
      {
         "title":"The Beatles Anthology",
         "isbn":"001-MJ",
         "age":23,
         "name":"Mark Janel"
      },
      {
         "title":"Anthology From Zero To Expert",
         "isbn":"002-MJ",
         "age":23,
         "name":"Mark Janel"
      }
   ],
   "pageable":{
      "sort":{
         "sorted":true,
         "unsorted":false,
         "empty":false
      },
      "pageSize":2,
      "pageNumber":0,
      "offset":0,
      "paged":true,
      "unpaged":false
   },
   "totalElements":7,
   "totalPages":4,
   "last":false,
   "numberOfElements":2,
   "first":true,
   "sort":{
      "sorted":true,
      "unsorted":false,
      "empty":false
   },
   "number":0,
   "size":2,
   "empty":false
}

请注意,这是原始结果。有时候这就是你所需要的。否则,它可以在存储器中被进一步处理以赋予它不同的形状(例如,将一个作者的所有书籍分组在一个列表下)。您可以在服务器端或客户端执行此操作。

正如项 95项 96 突出显示的那样,这个SELECT COUNT可以通过SELECT子查询或使用COUNT(*) OVER()窗口函数在单个查询中被同化。为了依赖COUNT(*) OVER(),在AuthorBookDto中增加一个额外的字段来存储总行数:

public interface AuthorBookDto {

    public String getName();  // of author
    public int getAge();      // of author
    public String getTitle(); // of book
    public String getIsbn();  // of book

    @JsonIgnore
    public long getTotal();
}

此外,按如下方式触发本机查询:

@Transactional(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age, b.title AS title,
                b.isbn AS isbn, COUNT(*) OVER() AS total FROM author a
                LEFT JOIN book b ON a.id = b.author_id WHERE a.genre = ?1",
      nativeQuery = true)
List<AuthorBookDto> fetchListOfDtoNative(
               String genre, Pageable pageable);

调用fetchListOfDtoNative()的服务方法如下所示:

public Page<AuthorBookDto> fetchPageOfAuthorsWithBooksDtoByGenreNative(
       int page, int size) {

    Pageable pageable = PageRequest.of(page, size,
       Sort.by(Sort.Direction.ASC, "name"));

    List<AuthorBookDto> listOfAuthors = authorRepository
        .fetchListOfDtoNative("Anthology", pageable);
    Page<AuthorBookDto> pageOfAuthors = new PageImpl(listOfAuthors,
        pageable, listOfAuthors.isEmpty() ? 0 :
            listOfAuthors.get(0).getTotal());

    return pageOfAuthors;
}

这一次,获取页面只需要一条 SQL 语句:

SELECT
  a.name AS name,
  a.age AS age,
  b.title AS title,
  b.isbn AS isbn,
  COUNT(*) OVER() AS total
FROM author a
LEFT JOIN book b
  ON a.id = b.author_id
WHERE a.genre = ?
ORDER BY a.name ASC LIMIT ? ?

有时不需要为每个页面触发一个SELECT COUNT,因为新的插入或删除非常罕见。因此,行数在很长时间内保持固定。在这种情况下,当获取第一页时触发单个SELECT COUNT,并使用SliceList进行分页,如下两种方法所示。

只要总行数与每页无关,使用Slice代替Page也是一种选择:

@Transactional(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age, b.title AS title,
                b.isbn AS isbn FROM Author a LEFT JOIN a.books b
                WHERE a.genre = ?1")
Slice<AuthorBookDto> fetchSliceOfDto(
    String genre, Pageable pageable);

public Slice<AuthorBookDto> fetchSliceOfAuthorsWithBooksDtoByGenre(
       int page, int size) {

    Pageable pageable = PageRequest.of(page, size,
       Sort.by(Sort.Direction.ASC, "name"));
    Slice<AuthorBookDto> sliceOfAuthors = authorRepository
       . fetchSliceOfDto("Anthology", pageable);

    return sliceOfAuthors;
}

同样需要一个SELECT:

SELECT
  author0_.name AS col_0_0_,
  author0_.age AS col_1_0_,
  books1_.title AS col_2_0_,
  books1_.isbn AS col_3_0_
FROM author author0_
LEFT OUTER JOIN book books1_
  ON author0_.id = books1_.author_id
WHERE author0_.genre = ?
ORDER BY author0_.name ASC LIMIT ? ?

当然,依靠List而不是Page / Slice也会触发一条 SQL 语句,但是这样就没有可用的页面元数据了:

@Transactional(readOnly = true)
@Query(value = "SELECT a.name AS name, a.age AS age, b.title AS title,
               b.isbn AS isbn FROM Author a LEFT JOIN a.books b
               WHERE a.genre = ?1")
List<AuthorBookDto> fetchListOfDto(String genre, Pageable pageable);

public List<AuthorBookDto> fetchListOfAuthorsWithBooksDtoByGenre(
                                               int page, int size) {

    Pageable pageable = PageRequest.of(page, size,
        Sort.by(Sort.Direction.ASC, "name"));
    List<AuthorBookDto> listOfAuthors
        = authorRepository.fetchListOfDto("Anthology", pageable);

    return listOfAuthors;
}

调用fetchListOfAuthorsWithBooksDtoByGenre()触发与Slice相同的SELECT。这一次,生成的 JSON 不包含任何页面元数据。

这一次,我们使用Pageable只是为了通过 Spring help 添加用于排序和分页的 SQL 子句。特别是在分页的时候,Spring 会根据方言选择合适的 SQL 子句(例如,对于 MySQL,它会添加LIMIT)。

到目前为止,您已经看到了几种获取只读结果集的方法,该结果集包含作者和相关书籍的列子集。由于分页,这些方法的主要问题是它们容易截断结果集。因此,一个作者只能用他的一部分书来获取。图 3-16 显示了马克·詹妮尔有三本书,但其中两本列在第一页,而第三本列在第二页。

img/487471_1_En_3_Fig16_HTML.jpg

图 3-16

截断结果集的分页

有时这根本不是问题。比如图 3-16 的输出就没问题。如何避免结果集被截断?如果这是您的应用设计的一个要求,该怎么办呢?

DENSE_RANK()窗口函数来救援

DENSE_RANK是一个窗口函数,它为每组ba的不同值分配一个序列号。为此,DENSE_RANK新增一列,如图 3-17 ( na_rank)所示。

img/487471_1_En_3_Fig17_HTML.jpg

图 3-17

应用 DENSE_RANK()

一旦DENSE_RANK()完成了它的工作,查询可以简单地通过添加一个WHERE子句来获取页面中的作者,如下面的本机查询所示:

@Transactional(readOnly = true)
@Query(value = "SELECT * FROM (SELECT *,
                DENSE_RANK() OVER (ORDER BY name, age) na_rank
                FROM (SELECT a.name AS name, a.age AS age, b.title AS title,
                b.isbn AS isbn FROM author a LEFT JOIN book b ON a.id =
                b.author_id WHERE a.genre = ?1 ORDER BY a.name) ab ) ab_r
                WHERE ab_r.na_rank > ?2 AND ab_r.na_rank <= ?3",
       nativeQuery = true)
List<AuthorBookDto> fetchListOfDtoNativeDenseRank(
    String genre, int start, int end);

根据经验,使用本机查询来编写复杂的查询。这样,您可以利用窗口函数、常用表表达式(CTE)、PIVOT 64 等等。在适当的情况下使用原生查询可以大大提高应用的性能。不要忘记分析您的 SQL 查询和执行计划,以优化它们的结果。

调用fetchListOfDtoNativeDenseRank()的服务方法可以是:

public List<AuthorBookDto> fetchListOfAuthorsWithBooksDtoNativeDenseRank(
       int start, int end) {

    List<AuthorBookDto> listOfAuthors = authorRepository
        .fetchListOfDtoNativeDenseRank("Anthology", start, end);

    return listOfAuthors

;
}

例如,您可以在不截断图书的情况下获取前两位作者的图书,如下所示:

fetchListOfAuthorsWithBooksDtoNativeDenseRank(0, 2);

将结果集表示为一个 JSON 揭示了已经获取了两个作者的信息( Mark Janel 有三本书,而 Merci Umaal 有两本书):

[
   {
      "title":"The Beatles Anthology",
      "isbn":"001-MJ",
      "age":23,
      "name":"Mark Janel"
   },
   {
      "title":"Anthology From Zero To Expert",
      "isbn":"002-MJ",
      "age":23,
      "name":"Mark Janel"
   },
   {
      "title":"Quick Anthology",
      "isbn":"003-MJ",
      "age":23,
      "name":"Mark Janel"
   },
   {
      "title":"Ultimate Anthology",
      "isbn":"001-MU",
      "age":31,
      "name":"Merci Umaal"
   },
   {
      "title":"1959 Anthology",
      "isbn":"002-MU",
      "age":31,
      "name":"Merci Umaal"
   }
]

请注意,这是原始结果。它可以在存储器中被进一步处理,以赋予它不同的形状(例如,将一个作者的所有书籍分组在一个列表下)。这一次,没有使用Pageable,也没有可用的页面元数据,但是您可以很容易地添加一些信息(例如,通过调整查询以获取由DENSE_RANK()分配的最大值,您可以获得作者的总数)。完整的应用可在 GitHub 65 上获得(每个查询由BookstoreController中的 REST 端点公开)。

第 45 项:如何对结果集进行流式处理(在 MySQL 中)以及如何使用 Streamable 实用程序

在这一项中,我们讨论结果集的流式传输(在 MySQL 中)和使用Streamable实用程序类。

流式传输结果集(在 MySQL 中)

Spring Data JPA 1.8 支持通过 Java 8 Stream API 对结果集进行流式处理(这个特性在 JPA 2.2 中也有)。对于在单个往返行程中获取整个结果集的数据库(例如,MySQL、SQL Server、PostgreSQL),流可能会导致性能下降。这种情况在处理大型结果集时尤为突出。在某些情况下(需要基准来识别这种情况),开发人员可以通过以下方式避免这些性能问题:

  • 只进结果集(默认为 Spring 数据)

  • 只读语句(添加@Transactional(readOnly=true))

  • 设置提取大小值(例如 30,或逐行)

  • 对于 MySQL,将 fetch-size 设置为Integer.MIN_VALUE,或者通过将useCursorFetch=true添加到 JDBC URL 来使用基于光标的流,然后设置HINT_FETCH_SIZE提示或调用setFetchSize(size),其中size是每次要获取的行数

然而,在流的情况下,响应时间随着结果集的大小呈指数增长。在这种情况下,分页和批处理(成批轮询)比流式传输大型结果集(需要基准)的性能更好。数据处理可以通过存储过程来完成。

根据经验,努力使 JDBC 结果集尽可能小。在 web 应用中,分页应该是更可取的!JPA 2.2 支持 Java 1.8 流方法,但是执行计划可能不如使用 SQL 级分页时有效。

好的,让我们看一个基于简单的Author实体的例子。储存库AuthorRepository公开了一个名为streamAll()的方法,该方法返回一个Stream<Author>:

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Query("SELECT a FROM Author a")
    @QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE,
                value = "" + Integer.MIN_VALUE))
    Stream<Author> streamAll();
}

一个服务方法可以如下调用streamAll():

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

    try ( Stream<Author> authorStream = authorRepository.streamAll()) {

        authorStream.forEach(System.out::println);
    }
}

完整的代码可以在 GitHub 66 上找到。这个应用也包含了useCursorFetch=true案例。

不要将 Stream 与 Streamable 实用程序混淆

Spring 数据允许你返回Streamable ( org.springframework.data.util.Streamable)。这是对Iterable或任何集合类型(如ListSet等)的替代。).Streamable提供了几个方法,允许您在Streamable的元素上直接过滤(filter())、映射(map())、平面映射(flatMap())等等。此外,它允许您通过and()方法连接一个或多个Streamable

考虑Author实体和以下返回Streamable的查询方法(即使这些方法依赖于查询构建器机制,也允许使用@Query):

Streamable<Author> findByGenre(String genre);

Streamable<Author> findByAgeGreaterThan(int age);

或者您可以将Streamable与弹簧突起结合,如下所示:

public interface AuthorName {

    public String getName();
}

Streamable<AuthorName> findBy();

从服务方法中调用这些方法非常简单:

public void fetchAuthorsAsStreamable() {

    Streamable<Author> authors
        = authorRepository.findByGenre("Anthology");
    authors.forEach(System.out::println);
}

public void fetchAuthorsDtoAsStreamable() {

    Streamable<AuthorName> authors
        = authorRepository.findBy();
    authors.forEach(a -> System.out.println(a.getName()));
}

此外,您可以调用Streamable API 方法。从性能的角度来看,注意以有缺陷的方式使用Streamable是非常容易的。获取一个Streamable结果集,并通过filter()map()flatMap()等对其进行分割,直到您只获得所需的数据,而不是编写一个查询(例如 JPQL)从数据库中获取所需的结果集,这是非常诱人和舒适的。您只是丢弃一些获取的数据,只保留需要的数据。获取比需要更多的数据会导致严重的性能损失。

不要获取不必要的列,只是通过 map()删除其中的一部分

提取比需要更多的列可能会导致严重的性能损失。因此不要使用Streamable,如下例所示。您需要一个只读的结果集,只包含流派选集的作者姓名,但是这个例子获取实体(所有列)并应用map()方法:

// don't do this
public void fetchAuthorsNames() {

    Streamable<String> authors
        = authorRepository.findByGenre("Anthology")
            .map(Author::getName);

    authors.forEach(System.out::println);
}

在这种情况下,使用Streamable和一个弹簧突出物仅提取name列:

Streamable<AuthorName> queryByGenre(String genre);

public void fetchAuthorsNames() {

    Streamable<AuthorName> authors
        = authorRepository.queryByGenre("Anthology");

    authors.forEach(a -> System.out.println(a.getName()));
}

不要获取多余的行,只是通过 filter()删除其中的一部分

获取比需要更多的行也可能导致严重的性能损失。因此不要使用Streamable,如下例所示。您需要一个只包含大于 40 的流派选集的作者的结果集,但是您获取了流派选集的所有作者,然后应用filter()方法来保留大于 40 的作者:

// don't do this
public void fetchAuthorsOlderThanAge() {

    Streamable<Author> authors
        = authorRepository.findByGenre("Anthology")
            .filter(a -> a.getAge() > 40);

    authors.forEach(System.out::println);
}

在这种情况下,只需编写适当的 JPQL(通过查询构建器机制或@Query)在数据库级别过滤数据,并只返回所需的结果集:

Streamable<Author> findByGenreAndAgeGreaterThan(String genre, int age);

public void fetchAuthorsOlderThanAge() {

    Streamable<Author> authors
        = authorRepository.findByGenreAndAgeGreaterThan("Anthology", 40);

    authors.forEach(System.out::println);
}

请注意通过和( )连接 Streamable

Streamable可用于通过and()方法连接/组合查询方法结果。例如,让我们连接findByGenre()findByAgeGreaterThan()查询方法:

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

    Streamable<Author> authors
        = authorRepository.findByGenre("Anthology")
            .and(authorRepository.findByAgeGreaterThan(40));

    authors.forEach(System.out::println);
}

不要假设连接这两个Streamable会触发一个 SQL SELECT语句!每个Streamable产生一个单独的 SQL SELECT,如下所示:

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
WHERE author0_.genre = ?

SELECT
  author0_.id AS id1_0_,
  author0_.age AS age2_0_,
  author0_.genre AS genre3_0_,
  author0_.name AS name4_0_
FROM author author0_
WHERE author0_.age > ?

结果Streamable将两个结果集连接成一个结果集。这就好比说,第一个结果集包含给定流派的所有作者(选集),而第二个结果集包含给定年龄以上的所有作者( 40 )。最终结果集包含这些结果集的串联。

换句话说,如果一个作者拥有流派 【选集】 并且年龄大于 40 ,那么它们将在最终结果集中出现两次。这与编写如下代码不是一回事(不会产生相同的结果集):

@Query("SELECT a FROM Author a WHERE a.genre = ?1 AND a.age > ?2")
Streamable<Author> fetchByGenreAndAgeGreaterThan(String genre, int age);

@Query("SELECT a FROM Author a WHERE a.genre = ?1 OR a.age > ?2")
Streamable<Author> fetchByGenreAndAgeGreaterThan(String genre, int age);

或者通过查询构建器机制:

Streamable<Author> findByGenreAndAgeGreaterThan(String genre, int age);

Streamable<Author> findByGenreOrAgeGreaterThan(String genre, int age);

所以,注意你所期望的,以及你如何解释串联两个或更多Streamable的结果。

此外,根据经验,如果可以通过单个SELECT获得所需的结果集,就不要连接Streamable s。额外的SELECT语句增加了无意义的开销。

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

如何返回自定义可流式传输的包装类型

一种常见的做法是为映射查询结果集得到的集合公开专用的包装器类型。这样,在执行一次查询时,API 可以返回多个结果。在调用返回集合的查询方法后,可以通过手工实例化包装类来将其传递给包装类。如果代码遵循以下要点,您可以避免手动实例化。

  • 该类型实现了Streamable

  • 该类型公开了一个构造函数(接下来使用)或一个名为of(...)valueOf(...)的静态工厂方法,并以Streamable作为参数

考虑具有以下持久字段的Book实体:idpricetitleBookRepository包含一个单一的查询方法:

Books findBy();

注意findBy()方法的返回类型。我们不回一个Streamable!我们返回一个代表自定义Streamable包装类型的类。Books类跟在两个项目符号后面,如下所示:

public class Books implements Streamable<Book> {

    private final Streamable<Book> streamable;

    public Books(Streamable<Book> streamable) {
        this.streamable = streamable;
    }

    public Map<Boolean, List<Book>> partitionByPrice(int price) {

        return streamable.stream()
            .collect(Collectors.partitioningBy((Book a)
                -> a.getPrice() >= price));
    }

    public int sumPrices() {
        return streamable.stream()
            .map(Book::getPrice)
            .reduce(0, (b1, b2) -> b1 + b2);
    }

    public List<BookDto> toBookDto() {
        return streamable
            .map(b -> new BookDto(b.getPrice(), b.getTitle()))
            .toList();
    }

    @Override
    public Iterator<Book> iterator() {
        return streamable.iterator();
    }
}

正如您所看到的,这个类公开了三个方法,它们操作传递的Streamable来返回不同的结果:partitionByPrice()sumPrices()toBookDto()。此外,服务方法可以利用Books类:

@Transactional
public List<BookDto> updateBookPrice() {

    Books books = bookRepository.findBy();

    int sumPricesBefore = books.sumPrices();
    System.out.println("Total prices before update: " + sumPricesBefore);

    Map<Boolean, List<Book>> booksMap = books.partitionByPrice(25);

    booksMap.get(Boolean.TRUE).forEach(
        a -> a.setPrice(a.getPrice() + 3));

    booksMap.get(Boolean.FALSE).forEach(
        a -> a.setPrice(a.getPrice() + 5));

    int sumPricesAfter = books.sumPrices();
    System.out.println("Total prices after update: " + sumPricesAfter);

    return books.toBookDto();
}

仅此而已!完整的应用可在 GitHub 68 上获得。

Footnotes 1

HibernateSpringBootDirectFetching

  2

HibernateSpringBootSessionRepeatableReads

  3

HibernateSpringBootLoadMultipleIdsSpecification

  4

HibernateSpringBootLoadMultipleIds

  5

HibernateSpringBootReadOnlyQueries

  6

HibernateSpringBootAttributeLazyLoadingBasic

  7

https://thoughts-on-java.org/dont-expose-entities-in-api/

  8

HibernateSpringBootAttributeLazyLoadingDefaultValues

  9

HibernateSpringBootAttributeLazyLoadingJacksonSerialization

  10

HibernateSpringBootSubentities

  11

https://twitter.com/vlad_mihalcea/status/1207887006883340288

  12

HibernateSpringBootDtoViaProjectionsIntefaceInRepo

  13

HibernateSpringBootDtoViaProjections

  14

HibernateSpringBootDtoViaProjectionsAndJpql

  15

HibernateSpringBootDtoSpringProjectionAnnotatedNamedQuery

  16

HibernateSpringBootDtoSpringProjectionAnnotatedNamedNativeQuery

  17

HibernateSpringBootDtoSpringProjectionPropertiesNamedQuery

  18

HibernateSpringBootDtoSpringProjectionPropertiesNamedNativeQuery

  19

HibernateSpringBootDtoSpringProjectionOrmXmlNamedQuery

  20

HibernateSpringBootDtoSpringProjectionOrmXmlNamedNativeQuery

  21

HibernateSpringBootDtoViaClassBasedProjections

  22

HibernateSpringBootReuseProjection

  23

HibernateSpringBootDynamicProjection

  24

HibernateSpringBootDtoEntityViaProjection

  25

HibernateSpringBootDtoEntityViaProjectionNoAssociation

  26

HibernateSpringBootDtoViaProjectionsAndVirtualProperties

  27

HibernateSpringBootNestedVsVirtualProjection

  28

HibernateSpringBootProjectionAndCollections

  29

HibernateSpringBootJoinDtoAllFields

  30

HibernateSpringBootDtoConstructor

  31

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

  32

HibernateSpringBootDtoConstructorExpression

  33

HibernateSpringBootAvoidEntityInDtoViaConstructor

  34

HibernateSpringBootDtoTupleAndJpql

  35

HibernateSpringBootDtoTupleAndSql

  36

HibernateSpringBootDtoSqlResultSetMappingAndNamedNativeQuery2

  37

HibernateSpringBootDtoSqlResultSetMappingAndNamedNativeQuery

  38

HibernateSpringBootDtoSqlResultSetMappingNamedNativeQueryOrmXml

  39

HibernateSpringBootDtoViaSqlResultSetMappingEm

  40

HibernateSpringBootDtoSqlResultSetMappingAndNamedNativeQueryEntity2

  41

HibernateSpringBootDtoSqlResultSetMappingAndNamedNativeQueryEntity

  42

HibernateSpringBootDtoResultTransformerJpql

  43

HibernateSpringBootDtoResultTransformer

  44

https://discourse.hibernate.org/t/hibernate-resulttransformer-is-deprecated-what-to-use-instead/232

  45

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

  46

https://discourse.hibernate.org/t/hibernate-resulttransformer-is-deprecated-what-to-use-instead/232

  47

HibernateSpringBootDtoCustomResultTransformer

  48

HibernateSpringBootSubselect

  49

https://persistence.blazebit.com/

  50

HibernateSpringBootDtoBlazeEntityView

  51

HibernateSpringBootJoinFetch

  52

HibernateSpringBootJoinVSJoinFetch

  53

HibernateSpringBootLeftJoinFetch

  54

HibernateSpringBootDtoUnrelatedEntities

  55

HibernateSpringBootDtoViaInnerJoins

  56

HibernateSpringBootDtoViaLeftJoins

  57

HibernateSpringBootDtoViaLeftExcludingJoins

  58

HibernateSpringBootDtoViaRightJoins

  59

HibernateSpringBootDtoViaRightExcludingJoins

  60

HibernateSpringBootDtoViaCrossJoins

  61

HibernateSpringBootDtoViaFullJoins

  62

HibernateSpringBootDtoViaFullOuterExcludingJoins

  63

HibernateSpringBootDtoViaFullJoinsMySQL

  64

https://vladmihalcea.com/how-to-map-table-rows-to-columns-using-sql-pivot-or-case-expressions/

  65

HibernateSpringBootJoinPagination

  66

HibernateSpringBootStreamAndMySQL

  67

HibernateSpringBootStreamable

  68

HibernateSpringBootWrapperTypeStreamable