Spring Security 7にアップグレードしたらxmlでの設定が使えなくなった

XMLファイルでSpring Securityを設定するSecurityConfigは非推奨になっていたが、今回完全に削除されたのかクラスファイルが見つからなくなった。

流石にJavaファイルで設定する方式に変更した。

元のxmlファイルは下記の通り。
※ExtendedAuthenticationSuccessHandler、ExtendedAuthenticationFailureHandlerはログイン成功失敗時に独自処理を行うためのラッパークラス。

<?xml version="1.0" encoding="UTF-8"?>

<beans:beans xmlns="http://www.springframework.org/schema/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:beans="http://www.springframework.org/schema/beans"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/security https://www.springframework.org/schema/security/spring-security.xsd">

    <!-- Resources not processed by spring security filters -->
    <http pattern="/images/**" security="none" />
    <http pattern="/scripts/**" security="none" />
    <http pattern="/styles/**" security="none" />

    <http>
        <intercept-url pattern="/error" access="permitAll" />
        <intercept-url pattern="/login*/**" access="permitAll" />
        <intercept-url pattern="/signup*" access="permitAll" />
        <intercept-url pattern="/admin/**" access="hasRole('ROLE_ADMIN')" />
        <intercept-url pattern="/**" access="isAuthenticated()" />

        <form-login login-page="/login" authentication-success-handler-ref="authenticationSuccessHandler" authentication-failure-handler-ref="authenticationFailureHandler" />
        <remember-me user-service-ref="userDetails" key="aaa" />
        <logout logout-url="/logout" logout-success-url="/login" invalidate-session="true" delete-cookies="aaa" />
    </http>

    <authentication-manager alias="authenticationManager">
        <authentication-provider user-service-ref="userDetails">
            <password-encoder ref="passwordEncoder" />
        </authentication-provider>
    </authentication-manager>

    <beans:bean id="authenticationSuccessHandler" class="common.webapp.filter.ExtendedAuthenticationSuccessHandler">
        <beans:constructor-arg value="/top" />
    </beans:bean>

    <beans:bean id="authenticationFailureHandler" class="common.webapp.filter.ExtendedAuthenticationFailureHandler">
        <beans:property name="exceptionMappings">
            <beans:props>
                <beans:prop key="org.springframework.security.authentication.DisabledException">/login/accountDisabled</beans:prop>
                <beans:prop key="org.springframework.security.authentication.LockedException">/login/accountLocked</beans:prop>
                <beans:prop key="org.springframework.security.authentication.AccountExpiredException">/login/accountExpired</beans:prop>
                <beans:prop key="org.springframework.security.authentication.CredentialsExpiredException">/login/credentialsExpired</beans:prop>
                <beans:prop key="org.springframework.security.authentication.BadCredentialsException">/login/badCredentials</beans:prop>
            </beans:props>
        </beans:property>
    </beans:bean>

    <beans:bean id="webexpressionHandler" class="org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler" />

</beans:beans>

これに対して、Javaファイルは下記の通り。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final UserDetailsService userDetailsService;

    public SecurityConfig(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/images/**", "/scripts/**", "/styles/**",
                                 "/error", "/login*/**", 
                                 "/signup*").permitAll()
                .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
                .permitAll()
            )
            .rememberMe(remember -> remember
                .userDetailsService(userDetailsService)
                .key("aaa")
            )
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID", "aaa")
            );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public PasswordEncoder passwordTokenEncoder() {
        return new BCryptPasswordEncoder();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new ExtendedAuthenticationSuccessHandler("/top");
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        // 例外マッピングの設定
        Properties mappings = new Properties();
        mappings.put(DisabledException.class.getName(), "/login/accountDisabled");
        mappings.put(LockedException.class.getName(), "/login/accountLocked");
        mappings.put(AccountExpiredException.class.getName(), "/login/accountExpired");
        mappings.put(CredentialsExpiredException.class.getName(), "/login/credentialsExpired");
        mappings.put(BadCredentialsException.class.getName(), "/login/badCredentials");

        ExtendedAuthenticationFailureHandler handler = new ExtendedAuthenticationFailureHandler();
        handler.setExceptionMappings(mappings);
        return handler;
    }
}

Java 25のパフォーマンス改善点で気になったところ

特に気になった点だけ調べてみた。

ウォームアップ短縮(JEP 515)

Java アプリケーションは JIT コンパイルによって実行速度を最適化するが、その「最適化」に到達するまでのウォームアップ時間は長年の課題だった。特に短命なサービスやバースト型のワークロードでは、起動直後のレスポンス低下が無視出来ない。

JEP 515 では、過去の実行から収集したプロファイル情報を AOT キャッシュとして保存し、次回の起動時に利用できる仕組みが導入された。これにより、

  • 初期起動から JIT 最適化が効いた状態に近いパフォーマンスを発揮
  • マイクロサービスやサーバレス環境の「コールドスタート問題」を緩和
  • ウォームアップを待たずにスループットやレイテンシが安定

といったメリットがある。

トレーニング実行(プロファイル収集)

まずはアプリを「本番に近い負荷テスト」、「代表的な入力データ」等で実行し、JIT がどのメソッドをよく使うかを記録する。

java -XX:AOTCacheOutput=app-prof.aot -jar myapp.jar

本番実行(プロファイル利用)

保存したプロファイルを読み込み、本番環境で起動する。

java -XX:AOTCache=app-prof.aot -jar myapp.jar

Compact Object Headers(JEP 519)

Java のオブジェクトはヒープ上で管理され、各オブジェクトには「ヘッダ情報」が付与されている。従来は 12 バイトが標準だったが、JEP 519 では ヘッダを 8 バイトに圧縮するオプションが提供された。

これにより、

  • メモリ削減:多数の小さなオブジェクトを扱うアプリで特に有効。ヒープ効率が上がるため GC 回数も減少する。
  • キャッシュ効率の改善:メモリ占有が減ることで CPU キャッシュに収まるデータ量が増え、アクセスレイテンシが低下する。
  • スループット向上

利用方法は、JVM 起動時に以下を指定する。

java -XX:+UseCompactObjectHeaders -jar myapp.jar

デフォルトでONになっていないということは、全ての環境で改善するわけではないことを表している。

  • COH ではオブジェクトヘッダに格納されていた情報を圧縮/再配置するため、hashCode/同期多用のアプリでは遅くなる可能性
  • ヒープダンプ解析ツールや JVMTI を使った低レベルの計測では、
    オブジェクトレイアウトが従来と変わるため、互換性のリスクあり
  • メモリ帯域が十分に広いサーバー、CPU キャッシュが大きい最新世代のCPUでは効果が限定的になる場合がある(逆に、組込み系やクラウドVMのようにメモリ制約がある環境で効果大)

適用シナリオ

これらの最適化はすべてのアプリケーションで均等に効くわけではない。効果が大きいシナリオは以下の通りとなる。

ウォームアップ短縮(JEP 515)

  • サーバレス、マイクロサービス
  • 短命な CLI ツールやバッチ処理

Compact Object Headers(JEP 519)

  • ヒープ上に数百万単位のオブジェクトを保持するシステム
  • 高トラフィックの Web サービス
  • データ処理や分析基盤

まとめ

Java 25 の JEP 515 と JEP 519 は、単なる言語仕様の追加ではなく、実行基盤のボトルネックを狙い撃ちした改善となっている。
起動直後のパフォーマンスが課題であればウォームアップ短縮を、ヒープ使用量やGCコストに悩んでいるなら Compact Object Headers を試す価値がある。

Spring Data JPA 3.5でSpecification.whereが非推奨になった件

/**
 * Simple static factory method to add some syntactic sugar around a {@link Specification}.
 *
 * @apiNote with 4.0, this method will no longer accept {@literal null} specifications.
 * @param <T> the type of the {@link Root} the resulting {@literal Specification} operates on.
 * @param spec can be {@literal null}.
 * @return guaranteed to be not {@literal null}.
 * @since 2.0
 * @deprecated since 3.5, to be removed with 4.0 as we no longer want to support {@literal null} specifications.
 */
@Deprecated(since = "3.5.0", forRemoval = true)
static <T> Specification<T> where(@Nullable Specification<T> spec) {
    return spec == null ? (root, query, builder) -> null : spec;
}

非推奨になったけれども、まだ代替手段はないようだ。Nullを許容しなくなることを警告するためのものらしい。

The where method merely caters for the broken nullability allowance and is replaced by where(PredicateSpecification) in 4.0.

まだ確定ではないかもしれないが、mainでは下記の様に修正されている。

/**
 * Simple static factory method to add some syntactic sugar translating {@link PredicateSpecification} to
 * {@link Specification}.
 *
 * @param <T> the type of the {@link Root} the resulting {@literal Specification} operates on.
 * @param spec the {@link PredicateSpecification} to wrap.
 * @return guaranteed to be not {@literal null}.
 */
static <T> Specification<T> where(PredicateSpecification<T> spec) {

    Assert.notNull(spec, "PredicateSpecification must not be null");

    return (root, update, criteriaBuilder) -> spec.toPredicate(root, criteriaBuilder);
}

データベース上に「Null」が登録されていることと、Java変数が「Null」であることは意味合いが違うから、そのまま「Null」を渡すことをNGとした感じだろうか。

@Deprecatedを付けるのはまだ早かったのではないだろうか。(同じようにNullを許容しなくなる他のメソッドにはついていないし)

probably due to unaligned versions of the junit-platform-engine and junit-platform-launcher jars on the classpath/module path.が出るようになった件

EclipseでJunitを実行したら、「probably due to unaligned versions of the junit-platform-engine and junit-platform-launcher jars on the classpath/module path.」とエラーが出るようになった。

Junit 5.12からは、launcherのバージョンを明示的に指定してクラスパスに通しておかないと、Eclipseで互換性のない古いバージョンが使われてしまい、エラーになる。

<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-launcher</artifactId>
    <version>1.12.1</version>
    <scope>test</scope>
</dependency>

Tomcat 10からTomcat 11にアップグレードする

動作環境

動作環境はTomcat 10と変わらなかった。

Jakarta EE

Jakarta EEのサポートバージョンが変わっている。
一部抜粋:参考
Tomcat 11
 Servlet 6.1
 JSP 4.0
 EL 6.0
 Java 17 and later

Tomcat 10
 Servlet 6.0
 JSP 3.1
 EL 5.0
 Java 11 and later

javax.*からjakarta.*に移行が済んでいれば、基本的にはプログラム変更なしで動くはず。

変更する場合

Tomcat 11のサポートバージョンにプログラムを書き換えてみる。

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_6_1.xsd"
  version="6.1">

pom.xml

Servlet 、JSPのバージョンをpom.xmlで指定することはそう無いと思う。

ELについては、Hibernate Validator 8.0で、Eclipse Expressly 5.0を使用しているので、jakarta.el-api 5.0が必要(Expresslyの依存関係に含まれている)になる。

Hibernate Validator 9.0であれば、jakarta.el-apiは6.0となるが、まだベータ版の段階である。

xsdのURLは正しいのにEclipseのエディタ上でエラーになる

xsdのURLは正しいのにDownloading external resources is disabled.と表示されてエラーになっている。

xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"

下記の設定を変更することで解決した。

Mavenの設定の「Download Artifact Javadoc」にチェックを入れる。

XML (Wild Web Developer)の設定の「Download external resources like referenced DTD, XSD」にチェックを入れる。

Hibernate Searchを7.2にアップデートしたらNoSuchMethodErrorが発生した

Hibernate Searchを7.2.1にアップグレードして、テストを実施したら下記のエラーが発生した。

NoSuchMethodError: 'java.lang.Object org.jboss.logging.Logger.getMessageLogger

コンパイルエラーにはなっていないが、テスト時のログ出力でエラーとなった。

他のライブラリとの依存関係の影響で、jboss-logging 3.5系を使用するようになっていた。3.6.0.Finalを使用するようにpom.xmlに依存関係を追記したところ、エラーは解消した。

<dependency>
    <groupId>org.jboss.logging</groupId>
    <artifactId>jboss-logging</artifactId>
    <version>3.6.0.Final</version>
</dependency>

Gradleの場合も同様。

implementation 'org.jboss.logging:jboss-logging:3.6.0.Final'

Spring BootでSessionをRedisに保存する

前回、Raspberry piで遊んだ時にTomcatによるSession Replicationを実装した。

今回はSpring BootとRedisでSession管理を外部化してSession Replicationを実装してみる。

Redisのインストール

sudo apt install redis

Redisの設定

複数サーバーからアクセス出来るようにするため、アクセス制限を削除する。

sudo vi /etc/redis/redis.conf
下記の通り修正

bind 0.0.0.0 ::0 # 一時的に使用するだけなので全てのIPからアクセスOKにしている

Spring Bootでの実装例

build.gradleの依存関係部分
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	implementation 'org.springframework.session:spring-session-data-redis'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Sessionオブジェクトの保存にRedisを使用するようにConfigを設定する。なお、外部にデータを保存することになるので、Sessionに入れるデータはSerializableである必要がある。

import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisWebSession;

@Configuration
@EnableRedisWebSession()
public class SessionConfig {
}
Controllerの実装例
@RestController
@AllArgsConstructor
public class SessionController {

    @GetMapping("/websession")
    public Mono<SessionForm> getSession(WebSession session) {
        session.getAttributes().putIfAbsent("key", 0);
        session.getAttributes().putIfAbsent("note", "Nothing!");

        var sessionForm = new SessionForm();
        sessionForm.setKey((Integer) session.getAttributes().get("key"));
        sessionForm.setNote((String) session.getAttributes().get("note"));

        return Mono.just(sessionForm);
    }

    @GetMapping("/websession/test")
    public Mono<SessionForm> testWebSessionByParam(@RequestParam(value = "key") Integer key,
            @RequestParam(value = "note") String note, WebSession session) {
        session.getAttributes().put("key", key);
        session.getAttributes().put("note", note);

        var sessionForm = new SessionForm();
        sessionForm.setKey((Integer) session.getAttributes().get("key"));
        sessionForm.setNote((String) session.getAttributes().get("note"));

        return Mono.just(sessionForm);
    }
}

完全なコードはこちら。
https://github.com/hide6644/spring-session

動作確認

下記URLにアクセスするとパラメーターがSessionに保存される。
localhost:8080/websession/test?key=222&note=helloworld

下記のURLにアクセスすると、先ほど保存した文字が表示される。
localhost:8080/websession

{“key”:222,”note”:”helloworld”}

SessionをRedisに保存しているので、Spring Bootアプリをスケールアウトしても、それぞれのアプリでSessionが共有されていることが確認できると思う。

$ redis-cli
127.0.0.1:6379> keys *
1) "spring:session:sessions:セッションID"
127.0.0.1:6379> exit

Plugin could not be resolved. Ensure the plugin’s groupId, artifactId and version are present.が表示される

問題点

Eclipseでpom.xml開くと下記artifactIdの行にワーニングが表示されていた。

<reporting>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-javadoc-plugin</artifactId>
==省略==
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-project-info-reports-plugin</artifactId>
==省略==

Plugin could not be resolved. Ensure the plugin's groupId, artifactId and version are present.
Additional information: Unable to resolve org.apache.maven.plugins:maven-javadoc-plugin
Plugin could not be resolved. Ensure the plugin's groupId, artifactId and version are present.
Additional information: Unable to resolve org.apache.maven.plugins:maven-project-info-reports-plugin

Maven Repository上に存在はしている。
https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-javadoc-plugin
https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-project-info-reports-plugin

修正点

原因は<reporting>のみ記述していたこと。下記のように<pluginManagement>にも記述したらワーニングは表示されなくなった。

<pluginManagement>
    <plugins>
        <plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-javadoc-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-project-info-reports-plugin</artifactId>
            </plugin>

Jxls 2から3にアップグレードする

<!-- https://mvnrepository.com/artifact/org.jxls/jxls-poi -->
<dependency>
    <groupId>org.jxls</groupId>
    <artifactId>jxls-poi</artifactId>
    <version>3.0.0</version>
</dependency>

メジャーバージョンが3に上がり、Javaコードを殆ど書き直すことになったため、そのメモを残す。

参考URL:https://jxls.sourceforge.net/migration-to-v3-0.html

Jxls 2

ほぼ全てが変わったので、比較対象にならないが、変更前のコードは下記の通りとなっている。

import org.jxls.area.Area;
import org.jxls.builder.AreaBuilder;
import org.jxls.builder.xls.XlsCommentAreaBuilder;
import org.jxls.common.CellRef;
import org.jxls.common.Context;
import org.jxls.util.TransformerFactory;

// 出力部分抜粋
try (InputStream is = getTemplateSource(getUrl(), request);
        OutputStream os = response.getOutputStream()) {
    var transformer = TransformerFactory.createTransformer(is, os);
    AreaBuilder areaBuilder = new XlsCommentAreaBuilder(transformer);
    List<Area> xlsAreaList = areaBuilder.build();
    var xlsArea = xlsAreaList.get(0);
    var context = new Context();

    model.entrySet().forEach(mode -> context.putVar(mode.getKey(), mode.getValue()));

    xlsArea.applyAt(new CellRef("Sheet1!A1"), context);
    transformer.write();
}

※getTemplateSourceはテンプレート用のエクセルファイルを読み込んでいるメソッド。

Jxls 3

Java部分は下記の通りかなりシンプルになった。テンプレートファイル自体は変更の必要はなかった。

import org.jxls.builder.JxlsStreaming;
import org.jxls.transform.poi.JxlsPoiTemplateFillerBuilder;

// 出力部分抜粋
try (InputStream is = getTemplateSource(getUrl(), request);
        OutputStream os = response.getOutputStream()) {
    JxlsPoiTemplateFillerBuilder.newInstance()
        .withStreaming(JxlsStreaming.STREAMING_ON)
        .withTemplate(is)
        .buildAndFill(model, new JxlsOutputStream(os));
}

ファイル出力用のクラスはファイルをローカルに保存するJxlsOutputFile.classのみ提供されているため、必要に応じて自分で実装する。今回はHttpResponseのOutputStreamに直接乗せたかったので、下記の通り実装した。

import java.io.IOException;
import java.io.OutputStream;

import org.jxls.builder.JxlsOutput;

/**
 * Excelを書き込むためのOutputStreamを提供するクラス.
 */
public class JxlsOutputStream implements JxlsOutput {

    private OutputStream os;

    public JxlsOutputStream(OutputStream os) {
        this.os = os;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public OutputStream getOutputStream() throws IOException {
        return os;
    }
}