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に関しては明示的にソートの条件を指定しない方が、良いのかもしれない。

Local-Variable Type Inference ローカル変数宣言型推論

Java 10から、型の宣言を「var」で置き換えて省略することが出来るようになった。例えば、下記のように、

        var context = new ClassPathXmlApplicationContext(CONFIG_LOCATION);

        try (var stockFile = Files.newBufferedReader(Paths.get("src/stock1.txt");) {
        for (var i = 0; i < args.length; i++) {
        var executeFlag = true;

と記述することが出来る。積極的に使った方が良いか悩む。

桁あふれの心配をしなくて良いので、for分の初期化式の型宣言には便利かもしれない。

Spring Boot 2.6でHibernate Search 6を動かす

依存関係

implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

実装

Entityクラスに@Indexed、メソッドに@FullTextFieldを追加する。

@Entity
@Table(name = "novel")
@Indexed
public class Novel implements Serializable {

...

    @FullTextField
    private String title;

Serviceクラスに検索処理のロジックを追加する。

@Service
public class NovelService {

...

    @Transactional
    public Stream<Novel> searchIndex(final String searchParameters) {
        SearchSession searchSession = Search.session(entityManager);

        SearchResult<Novel> result = searchSession.search(Novel.class)
                .where(f -> f.bool()
                        .must(f.match()
                                .field("title")
                                .matching("異世界")))
                .fetchAll();
        return result.hits().stream();
    }

実際に動くコードはGitHubに公開予定。公開完了したら、下記に追記する。

※4/22追記
NovelService.java – github

public Stream<Novel> searchIndex(final String searchParameters) {
    SearchSession searchSession = Search.session(entityManager);
    String operationSetExper = String.join("|", SearchOperation.SIMPLE_OPERATION_SET);
    Pattern pattern = Pattern.compile(
            "(\\p{Punct}?)(\\w+?)(" + operationSetExper + ")(\\p{Punct}?)(\\w+?)(\\p{Punct}?),",
            Pattern.UNICODE_CHARACTER_CLASS);
    Matcher matcher = pattern.matcher(searchParameters + ",");

    SearchResult<Novel> result = searchSession.search(Novel.class)
            .where(f -> f.bool(b -> {
                b.must(f.matchAll());
                while (matcher.find()) {
                    b.must(f.match().field(matcher.group(KEY))
                            .matching(matcher.group(VALUE)));
                }
            }))
            .fetchAll();

    return result.hits().stream();
}

Spring BootでLuceneAnalysisConfigurerがうまく動かなかった件

Spring Boot 2.6はHibernate Search 6に対応している。そこで、日本語の検索も出来るようにするため、lucene-analyzers-kuromojiのAnalyzerをLuceneAnalysisConfigurerで設定しようとした。しかし、公式のガイド通りやってもうまくいかなかったため、メモを残しておく。

依存関係

implementation 'org.apache.lucene:lucene-analyzers-kuromoji:8.11.1'
implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

LuceneAnalysisConfigurerの実装

@Component("customLuceneAnalysisConfigurer")
public class CustomLuceneAnalysisConfigurer implements LuceneAnalysisConfigurer {

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("japanese").instance(new JapaneseAnalyzer());
    }
}

CustomLuceneAnalysisConfigurerの設定

公式の例では、実装したクラスを直接crawlerapi.config.CustomLuceneAnalysisConfigurerのように指定していたのだが、上記のようにComponentとして登録してから、下記のように参照するようにしないとSpring Bootの起動でハングアップした。

spring:
  jpa:
    properties:
      hibernate:
        search.backend:
          analysis.configurer: customLuceneAnalysisConfigurer

設定したAnalyzerの使用方法

先ほど定義した名前で参照できる。

@FullTextField(analyzer = "japanese")

Hibernate Search 6.0 Migration

自作のアプリのマイグレーションする過程で問題になった個所を纏めておく。

Hibernate Search 6の変更点

APIが大幅に変わっているため、コード修正なしに移行は不可。APIをElasticsearchに最適化したそうなので、Luceneを使用してきたがこれを機にElasticsearchに変更することを検討しても良いかも知れない。

インデックスの再作成
// Hibernate5
FullTextEntityManager txtentityManager = Search.getFullTextEntityManager(entityManager);
MassIndexer massIndexer = txtentityManager.createIndexer();

// Hibernate6
SearchSession searchSession = Search.session(entityManager);
MassIndexer massIndexer = searchSession.massIndexer();

// 共通
massIndexer.start()
ファセットの作成

全く同じように移行は出来なかった。5の場合は戻り値がList<Facet>で、6の場合はMap<String, Long>になる。Stringの部分はフィールドの属性毎に変更する必要がある。

// Hibernate5
FullTextEntityManager txtentityManager = Search.getFullTextEntityManager(entityManager);
SearchFactory searchFactory = txtentityManager.getSearchFactory();
QueryBuilder builder = searchFactory.buildQueryBuilder().forEntity(searchedEntity).get();
FacetingRequest categoryFacetingRequest = builder.facet()
    .name(field + searchedEntity.getSimpleName()).onField(field).discrete()
    .orderedBy(FacetSortOrder.COUNT_DESC).includeZeroCounts(false).maxFacetCount(maxCount)
    .createFacetingRequest();

Query luceneQuery = builder.all().createQuery();
FullTextQuery fullTextQuery = txtentityManager.createFullTextQuery(luceneQuery);
FacetManager facetManager = fullTextQuery.getFacetManager();
facetManager.enableFaceting(categoryFacetingRequest);

return facetManager.getFacets(field + searchedEntity.getSimpleName());

// Hibernate6
SearchSession searchSession = Search.session(entityManager);
AggregationKey<Map<String, Long>> countByKey = AggregationKey.of(field);

SearchResult<?> result = searchSession.search(User.class)
    .where(f -> f.matchAll())
    .aggregation(countByKey, f -> f.terms()
        .field(field, String.class)
        .orderByCountDescending()
        .minDocumentCount(1)
        .maxTermCount(maxCount))
    .fetch(20);

result.hits();

return result.aggregation(countByKey);
アナライザーの変更

@Analyzer(impl = JapaneseAnalyzer.class)はなくなり、@FullTextField(analyzer = “japanese”)の様にでフィールド毎に指定する必要がある。しかも、カスタムアナライザーを別途自分で定義する必要がある。@NormalizerDefも使用出来なくなっているため、アノテーションで細かく処理を指定出来なくなっている。

public class CustomLuceneAnalysisConfigurer implements LuceneAnalysisConfigurer {

    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("japanese").instance(new JapaneseAnalyzer());
    }
}
hibernate.search.backend.analysis.configurer = <パッケージ>.CustomLuceneAnalysisConfigurer

LuceneAnalysisConfigurerを定義して、以下の様に指定する。

@FullTextField(analyzer = "japanese")
private String text;
FullTextQueryの廃止

今までは全文検索用クエリ(FullTextQuery)を作成するためにluceneを使用する場面があったが、それらはバックエンドに移動したから、今後は使用しないようにということか。luceneのQueryを使用しなくても検索ロジックを記述することが出来るようになった。

Search.session(entityManager).search(User.class)
        .where(f -> {
            if (userSearchCriteria.getUsername() == null && userSearchCriteria.getEmail() == null) {
                return f.matchAll();
            } else {
                return f.bool(b -> {
                    if (userSearchCriteria.getUsername() != null) {
                        b.should(f.match().field(UserSearchCriteria.USERNAME_FIELD)
                                .matching(userSearchCriteria.getUsername()));
                    }
                    if (userSearchCriteria.getEmail() != null) {
                        b.should(f.match().field(UserSearchCriteria.EMAIL_FIELD)
                                .matching(userSearchCriteria.getEmail()));
                    }
                });
            }
        })
        .sort(f -> f.field(UserSearchCriteria.USERNAME_FIELD + "Sort"))
        .fetchHits(Long.valueOf(pageRequest.getOffset()).intValue(), pageRequest.getPageSize());
起動設定の変更
// Hibernate5
<prop key="hibernate.search.lucene_version">LUCENE_CURRENT</prop>
<prop key="hibernate.search.default.directory_provider">filesystem</prop>
<prop key="hibernate.search.default.locking_strategy">simple</prop>
<prop key="hibernate.search.default.exclusive_index_use">true</prop>
<prop key="hibernate.search.default.indexBase">${hibernate.search.indexBase}</prop>

// Hibernate6
<prop key="hibernate.search.backend.lucene_version">LATEST</prop>
<prop key="hibernate.search.backend.directory.type">local-filesystem</prop>
<prop key="hibernate.search.backend.directory.root">${hibernate.search.indexBase}</prop>
<prop key="hibernate.search.backend.analysis.configurer">common.dao.impl.CustomLuceneAnalysisConfigurer</prop>

Beanアノテーションの変更
// Hibernate5
@Field
@Field(name = "usernameSort", normalizer = @Normalizer(definition = "userSort"))
@SortableField(forField = "usernameSort")
private String username;

@Field(name = "firstNameFacet", analyze = Analyze.NO)
@Facet(forField = "firstNameFacet")
private String firstName;

// Hibernate6
@FullTextField(analyzer = "japanese")
@KeywordField(name = "usernameSort", sortable = Sortable.YES)
private String username;

@KeywordField(name = "firstNameFacet", aggregable = Aggregable.YES)
private String firstName;
Spring Boot

マニュアル通り下記を追加すると、エラーが発生してしまう。まだ、対応していないのかもしれない。
Caused by: org.hibernate.search.util.common.AssertionFailure: Unexpected duplicate key: enabled — this may indicate a bug or a missing test in Hibernate Search. Please report it: https://hibernate.org/community/

implementation 'org.apache.lucene:lucene-analyzers-kuromoji:8.7.0'
implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.0.0.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.0.0.Final'

Thymeleaf Layout Dialectのth:withに関するエラー

バグなのか仕様変更なのか後で調べるためのメモ。バージョンアップ後から下記のような使い方するとエラーが発生する様になった。

user.html
<html xmlns="http://www.w3.org/1999/xhtml"
    xmlns:th="http://www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{templates/layout}"
    th:with="currentMenu = ${param.from} != null and ${param.from[0]} == 'list' ? 'admin' : 'userSaveForm'">
layout.html
<html xmlns="http://www.w3.org/1999/xhtml"
    xmlns:th="http://www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    th:with="lang = ${#locale.language}"
    th:lang="${#locale.language}">
Caused by: org.thymeleaf.exceptions.TemplateProcessingException: Could not parse as assignation sequence: "currentMenu=(((${param.from} !,lang=${#locale.language}" (template: "templates/layout" - line 5, col 5)

user.htmlのth:withを単純な代入式にすると、エラーにならない。

th:with="currentMenu = 'admin'"

GreenMail Ver. 1.6 Migration

GreenMailを1.6にバージョンアップするとAssertionErrorが発生する場合は、 以下の依存関係が悪さをしているので、

    <dependency>
      <groupId>com.sun.mail</groupId>
      <artifactId>javax.mail</artifactId>
    </dependency>

以下の様に変更すれば良い。

    <dependency>
      <groupId>com.sun.mail</groupId>
      <artifactId>jakarta.mail</artifactId>
    </dependency>

Ver. 1.6から依存関係にあるjavamailが変更された。

Spring Bootが2.3にバージョンアップ

いくつかのSpringプロジェクトが新しいバージョンに更新されている。

  • Spring Data Neumann
  • Spring HATEOAS 1.1
  • Spring Integration 5.3
  • Spring Kafka 2.5
  • Spring Security 5.3
  • Spring Session Dragonfruit

Validation関連のクラスを分離したようで、自分のプロジェクトでは以下の一文をbuild.gradleに追加する必要があった。

implementation 'org.springframework.boot:spring-boot-starter-validation'

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文のみ発行されるようになる。