Spring Data JPA 2.7.0から、JpaRepository#getByIdが非推奨になった。対応はgetByIdをgetReferenceByIdメソッドに変更するだけ。
カテゴリー: Java
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文のみ発行されるようになる。
StandardPasswordEncoderは非推奨
Deprecated.
Digest based password encoding is not considered secure. Instead use an adaptive one way function like BCryptPasswordEncoder, Pbkdf2PasswordEncoder, or SCryptPasswordEncoder. Even better use DelegatingPasswordEncoder which supports password upgrades. There are no plans to remove this support. It is deprecated to indicate that this is a legacy implementation and using it is considered insecure.
DelegatingPasswordEncoderを使用する場合は、下記の通り。
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class WebSecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// デフォルトはbcrypt
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
又は、
<bean id="passwordEncoder" class="org.springframework.security.crypto.factory.PasswordEncoderFactories" factory-method="createDelegatingPasswordEncoder" />
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<>();