Hibernate 6.3.0にアップデートしたらNullPointerExceptionが発生した

アップデートしたら下記のエラーが発生するようになった。

java.lang.NullPointerException: Cannot invoke "org.hibernate.boot.spi.MetadataImplementor.getEntityBindings()" because "this.metadata" is null

Hibernate 6.3.0のバグっぽい。下記で修正中。

HHH-17154 Fix NullPointerException is thrown when constructing Entity…

同日にリリースされた6.2.8に変更したところ、NullPointerExceptionは出なくなったため、しばらくこちらを利用することにした。

Spring Framework 6にアップグレードする

目的

Spring Framework 6がリリースされ、その他のパッケージも合わせて変更できそうだったので、勉強のために以前作ったプロジェクトをSpring Framework 6にアップグレードしてみることにした。

主な変更点

Jakarta EE 9への対応

大まかに言うとjavaxからjakartaに変わる。

import javax.servlet.http.HttpServletRequest;
    ↓
import jakarta.servlet.http.HttpServletRequest;

さらに、Jakarta EEに対応しているTomcat 10などに、デプロイする必要がある。

実際に変更した箇所

依存関係

ライブラリの変更、またはバージョンアップを行った。

  • greenmail 2
  • hibernate… 6.1
  • hibernate-search-mapper-orm => hibernate-search-mapper-orm-orm6 6.1
  • hibernate-validator 7
  • javax.el => jakarta.el 4
  • javax.servlet => jakarta.servlet 6
  • jakarta.mail 2
  • jakarta.xml.bind-api 3
  • jaxb-runtime 3
  • spring-core… 6
  • spring-data-jpa 3
  • spring-security… 6
  • thymeleaf-spring5 => thymeleaf-spring6 3.1
  • thymeleaf-extras-springsecurity5 => thymeleaf-extras-springsecurity6 3.1

コードの変更点

javaxをjakartaに書き換えただけの部分は省略する。

Spring MVC設定の変更

下記のようThymeleafのspring5パッケージをspring6に変更する。Resolver以外も同様に変更する。

    <bean id="templateResolver" class="org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver">

MultipartResolverはStandardServletMultipartResolverを使用する。
web.xmlの<servlet>タグに<multipart-config>の設定を追加すると有効になる。

<bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver" />

Thymeleaf Ver. 3.1にアップグレードも同時に行った場合は下記の変更も必要になる。
Thymeleaf 3.1にアップグレード

web.xmlの変更

下記の通り、変更する。

<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
  version="5.0">

まとめ

javaxからjakartaに変更する箇所は多いが、結構簡単にアップグレード出来た。

Hibernate ORMとHibernate Searchで検索結果をソートする

備忘録として、いくつかの実装例を記載しておく。

Hibernate ORMのソート(Spring Data JPAを使用する)

Spring Bootを使用していなくても、下記のように依存関係を読み込むことでSpring Data JPAを使用できる。

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jpa</artifactId>
    <version>${spring-data.version}</version>
</dependency>

クエリメソッドを使用する

ルールに沿ったメソッド名を使用することにより、自動的にHibernateへSQLが発行される。

public interface CrudRepository<T, ID> extends Repository<T, ID> {
    // 登録、または更新
    <S extends T> S save(S entity);      
    // プライマリーキーで検索
    Optional<T> findById(ID primaryKey); 
    // 全検索
    Iterable<T> findAll();               
    // 全件数
    long count();                        
    // 削除
    void delete(T entity);               
    // 存在チェック
    boolean existsById(ID primaryKey); 

例えば、小説データを全検索して、小説のタイトルでソートをかけたい場合、下記のように書く。複数のソート条件を指定する場合は続けて記載する。

@Repository
public interface NovelRepository extends JpaRepository<Novel, Long>, JpaSpecificationExecutor<Novel> {
    List<Novel> findAllByOrderByTitle();
}

※「findAll」の後に余分な「By」があるのは、ルール的にそこに検索条件が入ることになるため、今回は全検索なので「By」だけが残っている。

条件 APIを使用する

criteria を記述することにより、クエリの where 句を定義している場合、ソートは以下のように定義する。複数のソート条件を指定する場合は引数を追加する。

novelRepository.findAll(spec, JpaSort.by("title"))
@Repository
public interface NovelRepository extends JpaSpecificationExecutor<Novel> {
}

Hibernate Searchのソート

ソートの処理実装前に、エンティティに@KeywordFieldを追記する必要がある。

    /** タイトル */
    @FullTextField(analyzer = "japanese")
    @KeywordField(name = "titleSort", sortable = Sortable.YES)
    private String title;

検索時に上記で定義した名称をソート条件に指定する。

import org.hibernate.search.mapper.orm.Search;

@Service
public class NovelService {
    private final EntityManager entityManager;

    @Transactional
    public List<Novel> findAllIndex() {
        return Search.session(entityManager)
                .search(Novel.class)
                .where(f -> f.matchAll())
                .sort(f -> f.field("titleSort"))
                .fetchAllHits();
    }

ソートの条件を指定していないときは、スコアの高い順にソートされている。大雑把に言えば、スコアが高いほど、より多くの述語にマッチしているか、より良くマッチしていることを意味する。そのため、Hibernate Searchに関しては明示的にソートの条件を指定しない方が、良いのかもしれない。

JPAのパフォーマンス改善

使い方によってパフォーマンスが大きく変わる。こういう使い方が駄目というわけではなく、場合によっては別の方法を試した方が良いという例。

1.問題点

[YOMOU CRAWLER] 第2回 クラス図の作成」にある通りNovelには複数のHistoryがあって、更新のある度に追加保存している。

// 1.Novelのエンティティ
public class Novel extends BaseObject implements Serializable {

    /** 小説の更新履歴セット */
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "novel", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<NovelHistory> novelHistories = new HashSet<>();

    public void addNovelHistory(NovelHistory novelHistory) {
        novelHistories.add(novelHistory);
        novelHistory.setNovel(this);
    }
// 2.Novelの更新履歴のエンティティ
public class NovelHistory extends BaseObject implements Serializable {

    /** 小説 */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "novel_id")
    private Novel novel;
// 3.使用箇所
// Novelオブジェクトは永続化済み
if (novelHistory != null) {
    // 小説の更新履歴が作成された場合
    novel.addNovelHistory(novelHistory);
}

このように永続化された状態のオブジェクトにAddするだけで自動的にInsert文が発行されるため、Javaのビジネスロジック開発に集中することが出来る。通常はこれで問題ないのだが、NovelHistoryに既に大量のデータが保存されているとパフォーマンスが問題になる。

FetchType.LAZYを指定しているため、novelHistories.add(novelHistory)を実行するときに関連するHistoryがデータベースからSelectされ、novelHistories変数に格納される。上記の例ではInsert前に以下のようなSQLが実行されている。

select 省略 from novel_history where novel_id = ?;

つまり、1件追加したいだけなのに関連する全てのHistoryをSelectしてしまっている。何千件もHistoryがあればそれだけでパフォーマンスが悪化する。

2.回避策

今回は以下の様に修正してこの問題を回避した。

// 3’.使用箇所
if (novelHistory != null) {
    // 小説の更新履歴が作成された場合
    novelHistory.setNovel(novel);
}

novelHistoryは永続化されていないため、適切な箇所でsaveする必要があるが、こうすれば単純にInsert文のみ発行されるようになる。

HibernateでMultipleBagFetchExceptionが発生する

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

MultipleBagFetchExceptionがHibernateによってスローされる理由は、重複が発生する可能性があり、Hibernate用語でBagと呼ばれる順序付けられていないListが重複を削除することを想定していないため。

@Entity
@Table(name = "app_user")
public class User implements Serializable {

    @OneToMany(fetch = FetchType.LAZY, mappedBy="user", cascade = CascadeType.ALL)
    private List<UserNovelInfo> userNovelInfos = new ArrayList<>();
@Entity
@Table(name = "user_novel_info")
public class UserNovelInfo extends BaseObject implements Serializable {

    /** ユーザー */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "username")
    private User user;

Listを型に使用すると内部的には大体Bagが使用される。
重複が発生する可能性を除去出来ないのであれば、Setを使用するように変更することで例外は発生しなくなる。

    @OneToMany(fetch = FetchType.LAZY, mappedBy="user", cascade = CascadeType.ALL)
    private Set<UserNovelInfo> userNovelInfos = new HashSet<>();