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<>();

JPA応用編:列挙型とN:Nで紐付ける

以下の3点を通常のカラムのアノテーションの他に追加する必要がある。
@CollectionTableを使って関連テーブルとカラムを指定する。
@Enumeratedで永続化フィールドを列挙型として指定する。
@ElementCollectionでEntityではないクラスも使用できるようにする。

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

    /** 権限 */
    @Column(nullable = false)
    @CollectionTable(name = "app_user_roles", joinColumns = @JoinColumn(name = "username"))
    @Enumerated(EnumType.STRING)
    @ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER)
    private List<Role> roles;
/**
 * 権限
 */
public enum Role implements Serializable {

    /** 管理者 */
    ROLE_ADMIN,

    /** 一般 */
    ROLE_USER;
}
create table app_user (
    username varchar(16) not null,
    primary key (username)
);

create table app_user_roles (
    username varchar(16) not null,
    roles varchar(16) not null,
    primary key (username, roles)
);

[YOMOU CRAWLER] 第3回 エンティティの関連を作成する

前回作成したクラス図に従ってエンティティに関連を追加していく。
以下ソースコード抜粋。

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

    /** ユーザーの小説の付随情報 */
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<UserNovelInfo> userNovelInfos;
@Entity
@Table(name = "user_novel_info")
public class UserNovelInfo extends BaseObject implements Serializable {

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

    /** 小説 */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "novel_id")
    private Novel novel;
}
@Entity
@Table(name = "novel")
public class Novel extends BaseObject implements Serializable {

    /** ユーザーの小説の付随情報 */
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "novel", cascade = CascadeType.ALL)
    private Set<UserNovelInfo> userNovelInfo = new HashSet<>();

@OneToManyで1:Nの関係を表している。@ManyToOneはその逆にN:1の関係を表している。@JoinColumnで外部キーのカラムを指定する。なお、このnameは外部キーのカラムの名が参照のリレーションシップのプロパティの名または参照元のエンティティまたは埋め込み可能クラスのフィールド + “_” + 参照される主キーのカラムの名となっている場合は省略可能となるが、逆にわかり難いので省略しないようにしている。

[YOMOU CRAWLER] 第2回 クラス図の作成

前回データモデルを作成して随分間が空いてしまったので今の状態のクラス図を作成しておく。

第1回で作成したデータモデルを修正してユーザーに紐付く情報を別のテーブルに持つようにする。(図1)
ユーザーと小説はN:Nの関係とする。関係の情報にお気に入りや評価を持つようにする。

図1 データモデル

変更に伴って関連するクラスも追加する。

図2 クラス図

コードの修正については「マルチユーザ対応 #9」で徐々に行っていこうと思う。

Persistent Entitieを@RequestMappingメソッドの引数に使用してはいけない

Spring MVC等のフレームワークを使用するとHttpリクエストから自動的にJavaオブジェクトに変換してくれる。

@PutMapping
public String onSubmitByPutMethod(@Valid UserDetailsForm userDetailsForm, BindingResult result) throws IOException {

便利な機能だが下記の通りセキュリティリスクもあるので注意する。

例えばユーザー情報管理画面のリクエストをEntityクラスに設定するようにした場合、Httpリクエストを細工すると元の画面には無い項目をオブジェクトに設定出来てしまう。
そのままそのオブジェクトを永続化すれば、不正にパスワードや権限を変更出来てしまう。

参考:https://rules.sonarsource.com/java/tag/spring/RSPEC-4684

Hibernate Searchでソート機能を使用する

@SortableFieldを付けるとそのFieldに対してソート用のインデックスが作成されるようになる。

@Field(analyze = Analyze.NO)
@SortableField
private String description;

但し、ソート可能とするFieldはトークン化してはいけない。そのため、FieldをAnalyze.NOとするか、Normalizerを指定する必要がある。(AnalyzerとNormalizerは排他的なのでNormalizerを指定すると結果的にAnalyze.NOになっている)

@NormalizerDef(name = "novelSort", filters = @TokenFilterDef(factory = LowerCaseFilterFactory.class))
public class Novel extends BaseObject implements Serializable {

    @Field
    @Field(name = "descriptionSort", normalizer = @Normalizer(definition = "novelSort"))
    @SortableField(forField = "descriptionSort")
    private String description;

実は@SortableFieldを指定しなくてもソート自体は出来るがインデックスが無いので、パフォーマンスが悪い。

Sort sort = new Sort(new SortField("descriptionSort", SortField.Type.STRING))

FullTextQuery query = ...
query.setSort(sort);

Gradleでコードカバレッジのレポートを作成する

JaCoCoプラグインを使う。
https://docs.gradle.org/current/userguide/jacoco_plugin.html

build.gradleに下記を追記する。

plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        xml.enabled false
        csv.enabled false
        html.destination file("${buildDir}/jacocoHtml")
    }
}

これでタスクにjacocoTestReportを追加して実行すればレポートが作成されるようになる。
(デフォルトの出力先は$buildDir/reports/jacoco)

さらに、当プロジェクトではLombokを使用しているため、Lombokで自動生成されたコードをカバレッジの対象外にしたい。
プロジェクトのルートフォルダにlombok.configファイルを作成し、下記の通り記入することで対象外とすることが出来る。

lombok.addLombokGeneratedAnnotation = true

Spring Dataで戻り値をStreamにした場合は使用後にcloseすること

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-streaming

検索結果が格納されているStreamは使用後にcloseしないと駄目らしい。

return novelDao.findByUnreadTrueOrderByTitleAndNovelChapterId().collect(Collectors.toList());

上記を下記のように変更する必要がある。

try (Stream<Novel> novels = novelDao.findByUnreadTrueOrderByTitleAndNovelChapterId()) {
    return novels.collect(Collectors.toList());
}