Spring Data JPAのSpecificationでページング時にJOIN FETCHすると例外が発生する

@OneToMany でリレーションを設定したJPA Entityに対し、Spring Data JPAのSpecificationで動的クエリを発行している。

N+1問題の回避のため、 JOIN FETCH して findAll(Specification) で検索していたが、ページングすることとなり、 findAll(Specification, Pageable) に切り替えたところ、実行時に例外が発生するようになった。

原因と対応方法を調べたのでメモ。

問題

@OneToMany でリレーションを設定したJPA Entityに対し、Spring Data JPASpecificationで動的クエリを発行している。

JpaRepository#findAll した場合、デフォルトでは @OneToMany を付与したフィールドを参照したタイミングでリレーション先のエンティティ取得のSQLが実行され、N+1問題が発生する。

いくつか対応方法はあるが、 JOIN FETCH することで1クエリで関連エンティティも取得できるため、 Specification#toPredicate 内で Root#fetch し、 JpaSpecificationExecutor#findAll(Specification) で検索していた。

// Specificationをラムダ式でインスタンス化
return (root, query, cb) -> {
    root.fetch(EntityClass_.relatedEntities, JoinType.LEFT);

    // 検索条件を設定
}

同様の検索条件でページングすることとなり、単純に List を返す findAll から Page を返す JpaSpecificationExecutor#findAll(Specification, Pageable) に切り替えたところ、実行時に以下の例外が発生するようになった。

java.lang.IllegalArgumentException: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list [...] [select count(generatedAlias0) from EntityClass as generatedAlias0 inner join fetch generatedAlias0.relatedEntities as generatedAlias1 ...]

対応

エラーメッセージは「フェッチしているのに、SELECTで使ってない」的な内容。JPQLが出力されているが、そこにも select count(関連元エンティティ) が出力されている。

Page#getTotalElements で使用する件数取得のために、SELECT句を COUNT に変換したクエリが実行されるが、それにより例外が発生している模様。

エラーメッセージで検索すると spring data jpa specification join fetch is not working - Stack Overflow が出てきた。

その中のリンクから飛んだ、 java - Eager fetching in a Spring Specification - Stack Overflow に対応方法があった。

Specification#toPredicate の第2引数の CriteriaQuery#getResultType で戻り値の型を取得できる。

これが Long であればカウントクエリとみなし、その場合はフェッチしないようにすればいい。

return (root, query, cb) -> {
    if (Long.class != query.getResultType()) {
        root.fetch(EntityClass_.relatedEntities, JoinType.LEFT);
    }

    // 検索条件を設定
}

この変更で、例外なく実行できるようになった。

振り返り

spring data specification join fetch でWeb検索したら、そのものズバリなStack Overflowの質問も出てきた。

stackoverflow.com

ここでは Long.class 以外に long.class とも比較しているが、 Long.class だけで動いている。

うまいこと解消しなければ、検索とカウントでクエリを分けようかと思っていたので、シンプルに対応できてよかった。

参考

JPAでのN+1問題と対応方法

meetup-jp.toast.com

Springの@Queryに列挙型のフィールドの値を埋め込む

特定の値しか入らないテーブルのカラムに対応する列挙型を用意し、フィールドとして値を保持している。

ORMとしてはJPA Entityを使用しているが、かなり古いソースなのでEnumeratedConvertによる列挙型のフィールドマッピングなどは行われず、単純な int でフィールドが宣言されている。

データアクセスはSpring DataでJpaRepositoryを使っているが、QueryアノテーションでJPQLを書くときに、列挙型のフィールドを参照できないかと思い調べたのでメモ。

問題

特定の値をフィールドとして持った列挙型を用意している。

package example;

@Getter
@RequiredArgsConstructor
public enum ExampleEntityState {
    START(0), END(1);
    private final int value;
}

@Data
@Entity
public class ExampleEntity {
    private int state; // 0, 1 が入る
}

この値に対応するJPA Entityのフィールドは、Enumeratedアノテーションなどで列挙型に変換されておらず、 int で宣言されている。

Queryアノテーションのクエリ文字列内で、特定の値のデータを検索したいが、マジックナンバーは使いたくないので、列挙型を用いて値を参照したい。

だが、クエリ文字列は定数で記述する必要があるため、列挙型でもフィールド値の参照はできない。

// これはコンパイルエラーになる
@Query("SELECT e FROM ExampleEntity e WHERE e.state = " + ExampleEntityState.END.getValue())
List<ExampleEntity> findAllOfEndState();

対応

Query内でSpring Expression Language(SpEL)を使える。これを使えば、クエリ文字列内でもフィールドアクセスが可能。

spring.io

spring.pleiades.io

SpEL内で T(完全修飾クラス名).staticフィールドやメソッド と書くことで、staticフィールドにアクセスできる。列挙型の値にも、これでアクセスできた。また、フィールドアクセスは通常のEL式と同様、Getter経由であればフィールド名のみでアクセスできる。

クエリ文字列では先頭に : をつけ、今回の例だと以下のように記述できた。

// ?#{T...} でも動くが、Intellij上でシンタックスエラー扱いになる
@Query("SELECT e FROM ExampleEntity e WHERE e.state = :#{T(example.ExampleEntityState).END.value}")
List<ExampleEntity> findAllOfEndState();

振り返り

@Enumerated@Convert で列挙型をフィールドとしてマッピングしてあれば、列挙型の値を完全修飾クラス名付き(上記では example.ExampleEntityState.STARTexample.ExampleEntityState.END)をクエリ文字列に記述できるが、修正量が多くなることから今回はこの方法で対応した。

// 列挙型をフィールドとしてマッピングしている場合
@Query("SELECT e FROM ExampleEntity e WHERE e.state = example.ExampleEntityState.END")

また、JPQLではなくネイティブクエリでSQLを書きたいときにも、この方法は使えそう。

もうちょっと簡単な方法がありそうな気はする。

java.util.Dateを簡単にLocalDateやLocalDateTimeに変換する

Java8から java.time が標準APIに入ったが、それ以前に書かれたソースだったり、ライブラリとの兼ね合いで、まだまだ java.util.Date を扱う機会がある。

両方扱うときに困るのが変換方法で、だいたい検索すると LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); みたいなのが出てくる。

もうちょっと簡単に変換できる方法があるのでメモ。

環境

Java8以降

java.util.Dateからjava.time.LocalDateTimeなどへの変換

java.util.Date から直接 java.time.LocalDateTime などへは変換できないが、 java.sql パッケージの Date, Time, Timestamp には、それぞれ対応する java.time パッケージのクラスへの変換処理が、Java8の時点で用意されている。

なので、いったん java.util.Date#getTime() をもとに java.sql パッケージのクラスに変換するなどし、再度 java.time パッケージのクラスへの変換処理を呼んでやればいい。

java.util.Date からの変換であれば LocalDateTime にすることが多いと思うが、 LocalDateTime には日付部分を返す toLocalDate() と時刻部分を返す toLocalTime() があるので、とりあえず Timestamp を経由しておくのが簡単。

java.util.Date date = ...;

LocalDateTime localDateTime = new Timestamp(date.getTime()).toLocalDateTime();
LocalDate localDate = localDateTime.toLocalDate();
LocalTime localTime = localDateTime.toLocalTime();

Date#toInstant()ZoneId#systemDefault() を覚えるよりは、こちらのほうが楽だと思う。

java.time.LocalDateTimeなどからjava.util.Dateへの変換

java.time のクラスから java.util.Date への変換の場合、これまた java.sql パッケージの3クラスに、それぞれ対応する java.time パッケージのクラスを引数に取る valueOf メソッドが用意されている。

これらを使って java.sql パッケージのクラスに変換してやれば、いずれも java.util.Date を継承しているので、 java.util.Date として扱える。

正確に java.util.Dateインスタンスである必要がある場合は、 getTime() した値でコンストラクタを読んでやればいい。

LocalDateTime localDateTime = ...;

Timestamp timestamp = Timestamp.valueOf(localDateTime);
java.sql.Date sqlDate = java.sql.Date.valueOf(localDateTime.toLocalDate());
Time time = Time.valueOf(localDateTime.toLocalTime());

java.util.Date utilDate = new java.util.Date(timestamp.getTime());

これを覚えておくと、任意の日時の java.util.Date の作成が簡単にできるので、ユニットテストが捗る。

LocalDateTime testLocalDateTime = LocalDateTime.of(2021, 12, 19, 17, 30, 00);
Timestamp testTimestamp = Timestamp.valueOf(testLocalDateTime );
java.util.Date testDate = new java.util.Date(testTimestamp.getTime());

振り返り

いずれも、 java.sql パッケージのクラスと、 java.time パッケージのクラスの対応関係だけ覚えておけばいいので、いろんな変換方法を覚えておくよりは簡単かなと思う。

変換処理の検索結果上位に乗ってこないのは、 java.sql パッケージのクラスがあまり使われていないからだろうか?

余談

Groovy v2.5以降だと org.apache.groovy.datetime.extensions.DateTimeExtensions で、変換メソッドが拡張されている。

java.util.Date#toLocalDateTime() および LocalDateTime#toDate() など、相互変換が可能。

java.util.Calendar にも toLocalDateTime() などが拡張されている。

Kotlinでjava.io.FileをInputStream/OutputStreamやReader/Writerに変換する

Kotlinのコードレビューをしていて、java.io.File から InputStream に変換するとき、Javaっぽく Files#newInputStream を使っているコードがあった。

拡張関数で直接 File から変換できると指摘したら、知られていなかったのでメモ。

拡張関数の定義されたクラス

kotlin.io.FileReadWrite.kt に定義されている。

github.com

FileからInputStream/OutputStream

File#InputStream()FileInputStreamFile#outputStream()FileOutputStream に変換できる。

バッファリングはされていないが、Kotlinの InputStreamOutputStreamkotlin.io.IOStreams.kt に拡張関数が定義されており、 buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE)BufferedInputStreamBufferedOutputStream で、バッファリングされた形式に変換できる。

// InputStream
file.inputStream().buffered().use { ... }

// OutputStream
file.outputStream().buffered().use { ... }

FileからReader/Writer

File#bufferedReader(charset: Charset = Charsets.UTF_8, bufferSize: Int = DEFAULT_BUFFER_SIZE)BufferedReaderFile#bufferedWriter(charset: Charset = Charsets.UTF_8, bufferSize: Int = DEFAULT_BUFFER_SIZE)BufferedWriter で、直接バッファリングされた形式に変換できる。

// Reader
file.bufferedReader().use { ... }

// Writer
file.bufferedWriter().use { ... }

振り返り

JavaだとNIOを使うのがいいだろうが、Kotlinだと java.io.File への拡張関数が便利なので、 java.io.File ベースで実装していくのがよさそう。

JavaとKotlinを行き来している身としては、両方の書き方を覚えないといけないのが面倒ではある。

Intellij IDEAでSonarLintの自動解析を抑制する

Javaでの開発時、Intellij IDEAにSonarLintプラグインを入れてSonarQubeによる解析を行っているが、Intellijのキャッシュクリアをして再起動した後などは、解析完了までCPUを全部持っていかれ、プチフリーズ状態になってしまう。

ファイル単位で手動実行できるし、以前書いた方法でコードレビュー時には解析結果が連携されるので、自動解析は止めてもいいんじゃないかと思い、方法を調べたのでメモ。

停止方法

Stack Overflowにあった。

stackoverflow.com

Windows版の場合、以下の手順で自動解析を停止できる。

  1. FileからSettingsを開く
  2. Toolsを展開し、SonarLintをクリック
  3. 右フレームにSonarLintの設定が開くので、Settingsタブの `Automatically trigger Analysis' のチェックを外し、OKボタンでウィンドウを閉じる

振り返り

SonarLintの自動解析が行われる条件がよくわからず、いきなり実行されてはCPU100%近くになることがあったので、かなり快適になった。

Intellij IDEAのインデックスとか解析系の処理、なんであんなにCPU使うんだろう...

Javaで画像の特定の色を透過させる

前回、アップロードされた画像の加工をJavaでやったところ、追加で「透過していない画像であれば、白背景を透過できないか」との要望が。

ちょっと試してみたところ、何とかなったのでメモ。

環境

OpenJDK 11

実装

前回同様、 java.awt.image のクラスを利用。

透過済みかの判定

BufferedImage#isAlphaPremultiplied で、アルファが事前に乗算されている*1かは判定できるが、そうでない場合の透過は判定できないので、画像のピクセルを総当たりしてアルファが設定されているか判定する。

boolean hasAlpha(BufferedImage image) {
    // アルファが乗算済みか判定
    if (image.isAlphaPremultiplied()) {
        return true;
    }

    // 乗算済みでない場合、各ピクセルをチェックし、アルファが設定されているか判定
    var imageWidth = image.getWidth();
    var imageHeight = image.getHeight();

    for (int x = 0; x < imageWidth; x++) {
        for (int y = 0; y < imageHeight; y++) {
            var alpha = new Color(image.getRGB(x, y), true).getAlpha();

            // アルファ未設定の場合255になる
            if (alpha != 255) {
                return true;
            }
        }
    }

    return false;
}

特定の色を透過

RGBImageFilter を使って、特定の色の場合に透過させることができる。

class TransparentFilter extends RGBImageFilter {

    private static final int RGB_BITS = 0xFFFFFF;

    private final int targetRgb;

    TransparentFilter(int targetRgb) {
        // RGBだけ抽出
        this.targetRgb = targetRgb & RGB_BITS;
        canFilterIndexColorModel = true;
    }

    TransparentFilter(Color color) {
        this(color.getRGB());
    }

    @Override
    public int filterRGB(int x, int y, int rgb) {
        // ビット演算でRGBを抽出し、対象と一致した場合透過させる
        // アルファが0になればいいので、0を返す
        return (rgb & RGB_BITS) == targetRgb ? 0 : rgb;
    }

}

のようなサブクラスを作成。

以下のような処理で、色を指定して透過できた。

void transparent(File imageFile, Color color) throws IOException {
    var originalImage = ImageIO.read(imageFile);

    // 前述のメソッドで透過済みか判定し、透過済みなら何もせず返す
    if (hasAlpha(originalImage)) {
        return;
    }

    var imageFilter = new TransparentFilter(color);
    var imageProducer = new FilteredImageSource(
            originalImage.getSource(), imageFilter
    );
    var transparentImage = Toolkit
                .getDefaultToolkit()
                .createImage(imageProducer);

    // 元画像と同じ大きさの透過画像を作成
    var newImage = new BufferedImage(
            originalImage.getWidth(),
            originalImage.getHeight(),
            BufferedImage.TYPE_INT_ARGB
    );
    var graphics = newImage.createGraphics();
    graphics.drawImage(transparentImage, 0, 0, null);

    // ファイルを上書き
    ImageIO.write(newImage, "png", imageFile);
    return;
}

実行サンプル

表計算ソフトで適当な画像を用意。

f:id:hepokon365:20211115000117p:plain
適当にもほどがある画像

Color.WHITEColor.BLACK を指定してそれぞれ実行した結果が以下。

f:id:hepokon365:20211115000307p:plainf:id:hepokon365:20211115000356p:plain
左がWHITE、右がBLACK指定

振り返り

まさか2021年にもなって、Javaで画像処理をガッツリやるとは思わなかった。

もろもろの画像編集ツールとかを使うと簡単にできることだが、情報が少ない&古いので、なかなか面倒だった。

ただ、 java.awt.image は枯れ切ってるので、10年以上前の情報もそのまま使えたりするのはちょっと面白かった。

参考

TYPE_INT_ARGBTYPE_INT_ARGB_PRE の違いについて

stackoverflow.com

*1:RGBと別にAlphaを持って画像を描画する際に乗算する方法と、あらかじめRGBにAlphaを乗算しておく方法があり、後者を指す模様。それぞれ、TYPE_INT_ARGB と TYPE_INT_ARGB_PRE に相当する。

Javaで画像の縦横比を維持してリサイズする

アップロードされた画像を特定の高さに揃える、みたいな作業をすることになった。

サムネイル画像など、表示用であればCloudinaryimgixのような、リサイズ機能付きCDNサービスを使えばいいが、サーバー側で別途画像処理する必要があったので使えず。

Javaで実装しているので、ImageMagickをインストールして ProcessBuilder で動かそうとも思ったが、 ImageIO 使えばPure Javaで実装できるかと思ったが、縦横比を自前で計算する必要がありそうだった。

自動でリサイズする方法はないかと調べてみると、ライブラリがあったのでメモ。

縦横比を維持してリサイズするライブラリ

imgscalrというライブラリがあった。Mavenリポジトリにも登録済み

ライブラリといっても、含まれるのはstaticメソッドを持つ2クラスだけ、うち1クラスは非同期処理用なので実質1クラス。

実装内容もJava標準の画像処理系クラスのラッパーで、縦横比を維持してリサイズ後の幅と高さを自動で計算し、 java.awt.image.BufferedImage 経由でリサイズしている。

最新バージョンは4.2だが、リリースが2012年1月なので枯れている。ライセンスもApache License 2.0で使いやすいので、こちらを利用することとした。

使い方

ImageIO.read などで変換したい画像を BufferedImage として読み込み、 Scalr.resize メソッドを実行する。

引数違いで同名メソッドが複数オーバーロードされているが、 Scalr.Mode を指定することで、リサイズ方法を変更できる。

モード 概要
FIT_EXACT 指定された幅・高さにリサイズ
FIT_TO_WIDTH 指定された幅にリサイズ
FIT_TO_HEIGHT 指定された高さにリサイズ

今回のように高さを固定したい場合、 FIT_TO_HEIGHT してやれば、縦横比を保ったままリサイズされる。

File originalImageFile = Path.of(...).toFile();
int newHeight = 120;
BufferedImage originalImage = ImageIO.read(originalImageFile);
BufferedImage resizedImage = Scalr.resize(
        originalImage,
        Scalr.Method.QUALITY,
        Scalr.Mode.FIT_TO_HEIGHT,
        newHeight
);
File tempFile = Files.createTempFile("resized-", ".png").toFile();
ImageIO.write(resizedImage, "png", tempFile);

と書いてやると、画像の高さを120pxに変換できる。

Modeを FIT_EXACT にすると縦横120px、FIT_TO_WIDTH にすると幅120pxになる。

この例では数値を1つしか渡していないが、 widthheight の2つを渡すメソッドもある。幅と高さをそれぞれ指定し、Modeで FIT_EXACT を使用すると、指定した幅と高さに変換できる模様。

振り返り

地味に面倒な縦横比計算を任せられたので助かった。

余談

当初はImageMagickを使おうかなと思ったが、JavaからImageMagickを使うライブラリもある模様。

backport.net

JNIを使ってAPIを実行するJMagickと、ProcessBuilder を使うim4javaの2つ。

両方ともライセンスがLGPLなので、Javaで依存性管理ツール経由で利用するならアプリケーション側をLGPLにする必要はなさそう。

LGPLとJava - GNUプロジェクト - フリーソフトウェアファウンデーション

使うならim4javaのほうかと思う。最終リリースが2012年12月なので、最新のバージョンで使用できるかわからなかったが、こちらの記事を見ると、ImageMagick 7系でも動く模様。