Tempus Dominus Bootstrap Ver.5とFont Awesome Ver.5を同時に使用する

Font Awesome Ver.5を使用している場合、Tempus Dominus Bootstrapのdatetimepickerの時間アイコンが表示されない。表示されない理由はVer.4以前で使用していたアイコン「fa-clock-o」がVer.5から「fa-clock」に変わっているため。

そこで、以下の通り新しいアイコンを直接指定するように修正する。

$('#accountExpiredDatePicker').datetimepicker({
  icons: {
    time: 'fas fa-clock',
    date: 'fas fa-calendar',
    up: 'fas fa-arrow-up',
    down: 'fas fa-arrow-down'
  }
})
<div class="input-group date" id="accountExpiredDatePicker" data-target-input="nearest">
  <input type="text" class="form-control datetimepicker-input" th:classappend="${#fields.hasErrors('accountExpiredDate')} ? is-invalid" th:field="*{accountExpiredDate}" th:placeholder="#{datetimepicker.format}" data-target="#accountExpiredDatePicker" />
  <div class="input-group-append" data-target="#accountExpiredDatePicker" data-toggle="datetimepicker">
    <div class="input-group-text"><em class="fas fa-calendar"></em></div>
  </div>
</div>

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

無料のSSL証明書をインストールする

※2022/04/25追記 無料のSSL証明書をインストールする(Ubuntu 22.04版)を公開。

No-IPドメインでLet’s Encryptの証明書を使うことが出来る。

1.事前確認
・ドメイン名を取得していること(今回はNo-IPを使用する)
・80、443ポートが外部に開放されていること

2.Certbotをインストールする

yum install epel-release
yum install certbot python-certbot-apache

3.証明書取得する

certbot certonly --agree-tos --webroot -w /var/www/html/ -d hoge.no-ip.org

取得に成功した場合、証明書は以下に保存される。

ll /etc/letsencrypt/live/hoge.no-ip.org/

4.Apacheに証明書を設定し再起動する

バージョン2.4.7以前の場合

SSLCertificateFile /etc/letsencrypt/live/hoge.no-ip.org/cert.pem
SSLCertificateKeyFile /etc/letsencrypt/live/hoge.no-ip.org/privkey.pem
SSLCertificateChainFile /etc/letsencrypt/live/hoge.no-ip.org/chain.pem

バージョン2.4.8以降の場合

SSLCertificateFile /etc/letsencrypt/live/hoge.no-ip.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/hoge.no-ip.org/privkey.pem
systemctl restart httpd

5.証明書を自動的に更新する

証明書は90日で失効してしまうため、cron.dに以下のスクリプトを置いておく。
有効期限の30日前になると自動的に更新されるので、とりあえず毎日か、毎週実行する様にしておけば良い。

#!/bin/sh

/bin/certbot renew --webroot-path /var/www/html/ --post-hook "systemctl reload httpd"