Spring Boot 3.1にアップグレードする

Spring Bootの3.1がリリースされたため、早速アップグレードしたところ、Spring Securityのバージョンが6.1になっていた。

その結果、このクラスでは以下のメソッドが非推奨になった。
.exceptionHandling()
.cors()
.csrf()
.formLogin()
.httpBasic()
.authorizeExchange()
.and()

@Bean
public SecurityWebFilterChain securitygWebFilterChain(ServerHttpSecurity http) {
    return http
            .exceptionHandling()
            .authenticationEntryPoint((swe, e) -> {
                return Mono.fromRunnable(() -> {
                    swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                });
            }).accessDeniedHandler((swe, e) -> {
                return Mono.fromRunnable(() -> {
                    swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                });
            }).and()
            .cors().configurationSource(corsConfigurationSource())
            .and().csrf().disable()
            .formLogin().disable()
            .httpBasic().disable()
            .authenticationManager(authenticationManager)
            .securityContextRepository(securityContextRepository)
            .authorizeExchange()
            .pathMatchers(HttpMethod.OPTIONS).permitAll()
            .pathMatchers("/crawler-api/login").permitAll()
            .pathMatchers("/crawler-api/signup").permitAll()
            .pathMatchers("/crawler-api/users*").hasAuthority("ROLE_USER")
            .pathMatchers("/crawler-api/novels*").hasAuthority("ROLE_USER")
            .anyExchange().authenticated()
            .and().build();
}

非推奨になったメソッドには新たにラムダ式で記述出来る同名のメソッドが追加されている。今後はそちらを使用する。

新しいメソッドに置き換えると下記の通りとなる。

.exceptionHandling(exceptionHandling -> exceptionHandling
        .authenticationEntryPoint((swe, e) -> {
            return Mono.fromRunnable(() -> {
                swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            });
        }).accessDeniedHandler((swe, e) -> {
            return Mono.fromRunnable(() -> {
                swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
            });
        }))
.csrf(csrf -> csrf.disable())
.formLogin(formLogin -> formLogin.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.authenticationManager(authenticationManager)
.securityContextRepository(securityContextRepository)
.authorizeExchange(exchanges -> exchanges
        .pathMatchers(HttpMethod.OPTIONS).permitAll()
        .pathMatchers("/crawler-api/login").permitAll()
        .pathMatchers("/crawler-api/signup").permitAll()
        .pathMatchers("/crawler-api/users*").hasAuthority("ROLE_USER")
        .pathMatchers("/crawler-api/novels*").hasAuthority("ROLE_USER")
        .anyExchange().authenticated())
.build();

cors()についてはCorsConfigurationSourceのBeanが定義されていれば自動的に読み込まれるとのことなので、SecurityWebFilterChainからは削除した。
また、新しいメソッドを置き換えた結果、.and()は不要になった。

JunitでMock使用時、エラーになる場合がある

mockito-core-5.2.0.jar

JunitでMockを使用しているとき、下記のエラーが出る。何度も実行していると、たまに正常に終了するときもある。

Error creating bean with name 'userDao' defined in class path resource [common/service/applicationContext-test.xml]: Failed to instantiate [java.lang.Object]: Factory method 'mock' threw exception with message: Please don't pass any values here. Java will detect class automagically.

調べてみたら下記に理由が書いてあった。
automatically detect class to mock #2779

修正内容は以下の通り。

    <bean id="userDao" class="org.mockito.Mockito" factory-method="mock">
-        <constructor-arg value="common.dao.jpa.UserDao" />
+        <constructor-arg>
+            <value type="java.lang.Class">common.dao.jpa.UserDao</value>
+       </constructor-arg>
    </bean>

Java 8のOptionalクラスの考察

動機

Optionalについて、うまく使えていない気がするため、改めて整理してみる。

実装例

Nullチェック

nullの一番の問題点は、nullチェックを行わなくともコンパイルエラーにならないこと。そして実行時にNullPointerExceptionが発生する可能性があること。

例として、nullチェックを行う場合について考えてみる。

public String getFruit() {
    return 果物の名前または、nullを返却する処理とする;
}

public void fruitCheck() {
    var fruit = getFruit();

    if (fruit != null) {
        System.out.println(fruit + " are delicious.");            
    }
}

これを単純にOptionalに置き換えると下記のようになる。

public Optional<String> getFruit() {
    String name = 果物の名前または、nullを返却する処理とする;
    return Optional.ofNullable(name);
}

public void fruitCheck() {
    var fruit = getFruit();

    if (fruit.isPresent()) {
        System.out.println(fruit.get() + " are delicious.");
    }
}

これだと、nullチェックを行っているときと変わらない。この処理を典型的な関数型プログラミングのスタイルに書き換えてみる。

public void fruitCheck() {
    var fruit = getFruit();

    fruit.ifPresent(name -> System.out.println(name + " are delicious."));
}

elseの処理が必要な場合は、下記のように書ける。

fruit.ifPresentOrElse(name -> System.out.println(name + " are delicious."),
        () -> System.out.println("I'm hungry."));

Nullの場合にデフォルト値を設定する処理

getFruit()のメソッドでnullを返すのではなく、nullの場合デフォルトの値を返す場合を考えてみる。

public String getFruit() {
    String name = 果物の名前または、nullを返却する処理とする;

    if (name != null) {
        return name;
    } else {
        return "Apple";
    }
}

public void fruitCheck() {
    var fruit = getFruit();

    System.out.println(fruit + " are delicious.");            
}

Optionalに置き換えると下記のようになる。

public String getFruit() {
    String name = 果物の名前または、nullを返却する処理とする;

    return Optional.ofNullable(name).orElse("Apple");
}

関数型プログラミングのスタイルになって、Nullチェックのミスも減るだろうか。

Nullチェックするとともに値のチェックも行う場合

public void fruitCheck() {
    var fruit = getFruit();

    if (fruit != null && fruit.equals("Banana")) {
        System.out.println(fruit + " are very delicious.");            
    }
}
public void fruitCheck() {
    var fruit = getFruit();

    fruit.filter(name -> name.equals("Banana"))
            .ifPresent(name -> System.out.println(name + " are delicious."));
}

Javaでファイル内容を比較し、結果を別ファイルに出力する

目的

キーが1対1の関係になっている2つのファイルを1行ずつ読みだし内容を比較する。その比較結果を別のファイルに出力するプログラムをJavaで作成する。

要件

〇入力ファイル
 ・在庫ファイル
   商品コード
   在庫数
 ・注文ファイル
   商品コード
   注文数
 ※それぞれ、商品コードで一意、昇順になっている。

〇出力ファイル
 ・新在庫ファイル
   商品コード
   在庫数
 ・エラーファイル
   商品コード
   注文数
 新在庫ファイル・・・入力ファイルの在庫と注文を突合して、在庫数から注文数を引いた数量を記録する
 エラーファイル・・・注文に対して、在庫がない、在庫が足りない場合はエラーとし、当ファイルに出力する

〇その他
 入力ファイルのサイズが大きいため、1行ずつ読み込んで処理していく必要がある。

実装例

public void matching() {
    try (var stockFile = Files.newBufferedReader(Paths.get("src/stock.txt"));
            var orderFile = Files.newBufferedReader(Paths.get("src/order.txt"));
            var newStockFile = Files.newBufferedWriter(Paths.get("src/newStock.txt"));
            var errorOrderFile = Files.newBufferedWriter(Paths.get("src/errorOrder.txt"));) {
        // 入力ファイルの1行目を読み込む
        // 文字列からオブジェクトに変換する
        Stock stock = Stock.createFromLine(stockFile.readLine());
        Order order = Order.createFromLine(orderFile.readLine());

        // どちらかのファイルの行があるうちは処理を続ける
        while (stock.getKey() < Integer.MAX_VALUE || order.getKey() < Integer.MAX_VALUE) {
            // 比較するコードを取得。(この例ではコードを数値としている)
            // コードで昇順になっているのでコードの大小を比較すれば、
            // 一方のファイルにしかない商品もわかる
            int stockKey = stock.getKey();
            int orderKey = order.getKey();

            if (stockKey - orderKey == 0) {
                // コードが一致
                int qty = stock.getQuantity() - order.getQuantity();

                if (qty < 0) {
                    // 在庫不足
                    // エラーファイル出力
                    errorOrderFile.write(order.toErrorOrder("01").toLine());
                    errorOrderFile.newLine();
                } else {
                    stock.setQuantity(qty);
                }

                // オブジェクトから1行分の文字列を生成し、書き出す
                newStockFile.write(stock.toNewStock().toLine());
                newStockFile.newLine();
                // 次の行を読み込む
                stock = Stock.createFromLine(stockFile.readLine());
                order = Order.createFromLine(orderFile.readLine());
            } else if (stockKey - orderKey > 0) {
                // 商品なし
                // エラーファイル出力
                errorOrderFile.write(order.toErrorOrder("02").toLine());
                errorOrderFile.newLine();
                // 次の行を読み込む
                order = Order.createFromLine(orderFile.readLine());
            } else {
                // 注文なし
                newStockFile.write(stock.toNewStock().toLine());
                newStockFile.newLine();
                // 次の行を読み込む
                stock = Stock.createFromLine(stockFile.readLine());
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

※他にも「var」宣言を使える箇所があるが、わかりやすくするため、あえて通常の宣言にしている。

@Builder
@Data
public class Order {

    private String category;
    private String code;
    private Integer quantity;

    public Integer getKey() {
        if (category == null && code == null) {
            return Integer.MAX_VALUE;
        } else {
            return Integer.valueOf(category + code);
        }
    }

    public ErrorOrder toErrorOrder(String errorCode) {
        return ErrorOrder.builder()
                .category(category)
                .code(code)
                .quantity(quantity)
                .errorCode(errorCode)
                .build();
    }

    public static Order createFromLine(String line) {
        if (line == null) {
            return Order.builder().build();
        } else if (line.length() < 35) {
            throw new IllegalArgumentException();
        }

        return Order.builder()
                .category(line.substring(4, 5))
                .code(line.substring(6, 7))
                .quantity(Integer.valueOf(line.substring(8, 9)))
                .build();
    }
}

Spring Boot 3にアップグレードする

目的

Spring Boot 3がリリースされたので、以前作成したプロジェクトをアップグレードしてみることにした。

主な変更点

主な変更点は「Spring Framework 6にアップグレードする」に記載したことと同じ。

実際に変更した箇所

依存関係

ライブラリの変更、またはバージョンアップを行った。

  • Spring Boot 3
  • hibernate-search-mapper-orm => hibernate-search-mapper-orm-orm6 6.1

コードの変更点

javaxをjakartaに書き換えただけの部分は省略する。

Spring Securityの設定箇所で@Configurationの追記が必要になった。

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class WebSecurityConfig {
    ↓
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class WebSecurityConfig {

まとめ

gradleを使用していれば、ほとんどの依存関係の更新も自動で行われるので、かなり簡単にアップグレードできた。

Spring Framework 6にアップグレードする

目的

Spring Framework 6がリリースされ、その他のパッケージも合わせて変更できそうだったので、勉強のために以前作ったプロジェクトをSpring Framework 6にアップグレードしてみることにした。

主な変更点

Jakarta EE 9への対応

大まかに言うとjavaxからjakartaに変わる。

import javax.servlet.http.HttpServletRequest;
    ↓
import jakarta.servlet.http.HttpServletRequest;

さらに、Jakarta EEに対応しているTomcat 10などに、デプロイする必要がある。

実際に変更した箇所

依存関係

ライブラリの変更、またはバージョンアップを行った。

  • greenmail 2
  • hibernate… 6.1
  • hibernate-search-mapper-orm => hibernate-search-mapper-orm-orm6 6.1
  • hibernate-validator 7
  • javax.el => jakarta.el 4
  • javax.servlet => jakarta.servlet 6
  • jakarta.mail 2
  • jakarta.xml.bind-api 3
  • jaxb-runtime 3
  • spring-core… 6
  • spring-data-jpa 3
  • spring-security… 6
  • thymeleaf-spring5 => thymeleaf-spring6 3.1
  • thymeleaf-extras-springsecurity5 => thymeleaf-extras-springsecurity6 3.1

コードの変更点

javaxをjakartaに書き換えただけの部分は省略する。

Spring MVC設定の変更

下記のようThymeleafのspring5パッケージをspring6に変更する。Resolver以外も同様に変更する。

    <bean id="templateResolver" class="org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver">

MultipartResolverはStandardServletMultipartResolverを使用する。
web.xmlの<servlet>タグに<multipart-config>の設定を追加すると有効になる。

<bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver" />

Thymeleaf Ver. 3.1にアップグレードも同時に行った場合は下記の変更も必要になる。
Thymeleaf 3.1にアップグレード

web.xmlの変更

下記の通り、変更する。

<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
  version="5.0">

まとめ

javaxからjakartaに変更する箇所は多いが、結構簡単にアップグレード出来た。

Thymeleaf 3.1にアップグレード

公式に記載の通り、#request、#response、#session、#servletContextが使用できなくなった。

1.6 Removal of web-API based expression utility objects
The #request, #response, #session, and #servletContext are no longer available to expressions in Thymeleaf 3.1.

Thymeleaf 3.1: What’s new and how to migrate

下記の通り修正して対応した。

${#servletContext.getAttribute('assetsVersion')}
       ↓
${application.assetsVersion}
${#request.remoteUser}
       ↓
remoteUserをSessionに保持するようにして、
${session.remoteUser}

※ 2022/11/30 追記
Spring Securityを使用している場合、remoteUserを取得するなら、Sessionに保持は不要で、下記で可能だった。
${#authentication.name}

変数のコピー元を変更するとコピー先が変わる場合と変わらない場合

Javaにおいて変数のコピー先が変わらない例

String a = "りんご";  // (1)
String b = a; // 変数bにaを代入 (2)
System.out.println(a);
System.out.println(b);

実行結果
りんご
りんご
// 変数aに値を設定し直す
a = "みかん";  // (3)
System.out.println(a);
System.out.println(b);

実行結果
みかん
りんご

変数aは「みかん」に置き換わるが、変数bは変わらない。これは下記のようなイメージ。

 (1) String a = “りんご”のイメージ
  「りんご」の写真を撮り、その写真をaという箱に入れる。

 (2) String a = bのイメージ
  aの箱に入っている「りんご」の写真のコピーを取り、そのコピーをbの箱に入れる。

 (3) a = “みかん”のイメージ
  aの箱に入っていた「りんご」の写真を捨てて、aの箱に「みかん」の写真を入れる。

コピーを取ってbの箱に入れた「りんご」の写真はそのままなので、System.out.println(b)は「りんご」となる。

コピー先が変わる例

// 配列を作成する
String[] c = new String[] { "りんご", "バナナ" };  // (1)
String[] d = c;  // (2)

System.out.println(d[0]);
System.out.println(d[1]);

c[0] = "みかん";  // (3)

System.out.println(d[0]);
System.out.println(d[1]);

実行結果
りんご
バナナ
みかん
バナナ

配列の要素c[0]を変更すると、d[0]の内容も変わる。これは下記のようなイメージ。

 (1) String[] c = new String[] { “りんご”, “バナナ” }のイメージ
  「りんご」の写真を撮って、cという棚の[0]という箱に入れる。
  「バナナ」の写真を撮って、cという棚の[1]という箱に入れる。

 (2) String[] d = cのイメージ
  cの棚の箱を全て取り出して、中身をコピーするのは大変なので(今回は2個だけだが、通常はもっと多いことを想定している)、
  dという棚の[0]の位置にcという棚の[0]を見るようにとメモを置く。
  dという棚の[1]の位置にcという棚の[1]を見るようにとメモを置く。

 (3) c[0] = “みかん”のイメージ
  「みかん」の写真を撮って、cという棚の[0]という箱に入れる。

変数dには値のコピーはなく、参照先の情報しかないため、System.out.println(d[0])とするとc[0]の情報を参照することになり、コピー元と同様に表示される値が変わることになる。

同様に変数cとdを逆にしても、表示される値が変わる。

String[] c = new String[] { "りんご", "バナナ" };
String[] d = c;

System.out.println(c[0]);
System.out.println(c[1]);

d[0] = "みかん"; // コピー先を書き換える (4)

System.out.println(c[0]);
System.out.println(c[1]);

実行結果
りんご
バナナ
みかん
バナナ

 (4) d[0] = “みかん”のイメージ
  「みかん」の写真を撮って、dという棚の[0]という箱に入れようとしたら、そこに箱はなく、cという棚の[0]を見るようにとメモがあったので、cという棚の[0]の箱の中に「みかん」の写真を入れる。

(4)の操作でc[0]の内容が変わってしまっているので、System.out.println(c[0])の出力も「みかん」になる。

Log4jからLogbackに変更する

やりたいこと

色々バグのあったLog4jのログ出力部分のみを、Logbackに変更する。ソースコードには変更が入らないように移行する。

変更前

下記の例ではLog4j-core-2.18.0.jarが読み込まれて、ログ出力を行っている。

        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j-impl</artifactId>
            <version>2.18.0</version>
        </dependency>

Javaコードは下記のように記述されている。

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class SampleImpl {
    private static final Logger log = LogManager.getLogger(SampleImpl.class);

変更後

依存関係にlogback-classic、logback-coreを追加する。log4j-slf4j-implをlog4j-to-slf4jに変更する。

        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.4.1</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-core</artifactId>
            <version>1.4.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-to-slf4j</artifactId>
            <version>2.18.0</version>
        </dependency>

これで、logback-core-1.4.1.jarでログが出力されるようになる。Javaコードがコンパイルエラーになることもない。

Hibernate Search 6でFacetを作成したい

初めに

Hibernate Searchを使用して、キーワードに登場する単語の回数を集計したい。※例)残酷な描写あり: 873, 異世界: 481, ハーレム: 471

Hibernate 5まではFacet専用の機能が実装されていたのだが、Hibernate 6からはAggregation機能が実装され、より一般的な集計処理となった。

実装例

集計を実施したいエンティティの変数に下記のようにアノテーションを追加する。

@KeywordField(name = "keyword_facet", aggregable = Aggregable.YES)

集計処理

@Transactional
public Map<String, Long> aggregateByKeywords() {
    AggregationKey<Map<String, Long>> countsByKeywordKey = AggregationKey.of("countsByKeyword");

    return Search.session(entityManager)
            .search(Novel.class) // エンティティ
            .where(f -> f.matchAll()) // 全検索
            .aggregation(countsByKeywordKey, f -> f.terms()
                    .field("keyword_facet", String.class)
                    .maxTermCount(10)) // 登場回数の多い順10件まで
            .fetch(10)
            .aggregation(countsByKeywordKey);
}

集計結果

Mapにキーワードとその文字の登場回数が設定されているので、Loop処理等で展開する。以下はVueの実装例。

<span v-for="(value, name, index) in aggregateByKeywords" :key="index">
    <span v-if="index > 0">, </span>
    {{ name }}: {{ value }}
</span>

R15: 1074, 残酷な描写あり: 873, 異世界: 481, ハーレム: 471, ファンタジー: 451, 異世界転移: 389, チート: 378, 魔法: 362, 冒険: 319, 主人公最強: 296