Java
Thymeleaf
SpringBoot

Spring Boot 1.4系から2.0系へのマイグレーションでやったこと

はじめに

前回 は、SpringBoot 1.5からSpring Boot 2.0へのバージョンアップを行いました。
今回は別のプロジェクトで、1.4から2.0に上げた際にハマった事などを書いていきます。

今回主に苦労したのは、

  • 問題の原因が 1.4->1.5の部分なのか、1.5->2.0の部分なのかの切り分けに時間がかかった
  • Thymeleafの影響が大きいのでテストが大変
  • SpringSessionでのシリアライズ/デシリアライズまわり
  • 本番デプロイ時の対策

あたりです。

Spring Bootのバージョンを上げる

spring-boot-starter-parentのバージョンを更新

pom.xmlでSpringBootのバージョンを指定します。

pom.xml
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.3.RELEASE</version>
    <relativePath />
  </parent>

HikariPCの依存関係を削除

元々HikariPCを使用していたので、依存関係から削除します。

pom.xml
    <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    </dependency>

コンパイルしてエラーを潰していく

SpringBootServletInitializerが見つからない

1.png

SpringBootServletInitializer のパッケージが変わっているので、再importする

org.springframework.boot.context.embedded.が見つからない

2.png

パッケージが org.springframework.boot.web.servlet. に変わっているので再importする

DataSourceBuilderが見つからない

3.png

パッケージが変わっているので再importする

WebMvcConfigurerAdapterの非推奨化

extends WebMvcConfigurerAdapterimplements WebMvcConfigurer に変更する

@EnableWebMvcSecurityの非推奨化

@EnableWebSecurity に変更する。

https://docs.spring.io/spring-security/site/docs/current/reference/html/mvc.html

org.apache.velocity.appが見つからない

4.png

velocity から mustache に移行する。

pom.xml
-    <dependency>
-   <groupId>org.apache.velocity</groupId>
-   <artifactId>velocity</artifactId>
-    </dependency>
+    <dependency>
+      <groupId>org.springframework.boot</groupId>
+      <artifactId>spring-boot-starter-mustache</artifactId>
+    </dependency>

velocityのテンプレートファイル *.vm を mustacheのテンプレートファイル *.mustache に置換する。
※ここは application.properties などで変更可

mustacheのテンプレートの形式に合わせて

${hoge}
↓
{{hoge}}

こんな感じで全部置き換える

shellとかで一括置換してもいいけど、IntelliJのリファクタでやると呼び出し元コードも発見して教えてくれるので数が少なければIntelliJのリファクタがいいかもしれない。

org.jsonが見つからない

5.png

以下の依存関係を追加する

pom.xml
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-json</artifactId>
    </dependency>

以下のパッケージで importしなおす
org.springframework.boot.configurationprocessor.json.JSONObject

SpringApplication.runでエラー

6.png

SpringApplication.runの引数が変更になっている

以下のように修正

        public static void main(String[] args) {
-               Object[] objects = { HogeApplication.class, FugaService.class };
-               SpringApplication.run(objects, args);
+               final SpringApplication application = new SpringApplication(HogeApplication.class, FugaService.class);
+               application.run(args);
        }

ついでに以下のクラスを継承する

SpringBootServletInitializer

JPAのメソッド変更まわりの対応

7.png

黙々と直していく

https://spring.io/blog/2017/06/20/a-preview-on-spring-data-kay#improved-naming-for-crud-repository-methods

AutoConfigureTestDatabaseが見つからない

8.png

import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureTestDatabase;
から
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
にパッケージを変更

Thymeleafのマイグレーション

https://www.thymeleaf.org/doc/articles/thymeleaf3migration.html

th:substitutebyを th:replaceに置き換える

こんな感じで機械的に

find src/main/resources -type f -name "*.html" -print | xargs sed -i  -e 's/th:substituteby/th:replace/g'

linkタグのcss読み込みのtype="text/css"を削除

こんな感じで機械的に

find src/main/resources -type f -name "*.html" -print | xargs sed -i  -e 's@type=\"text/css\"@@g'

inline="text" / inline="inline"を削除

一応中身を見ながら、削除していく。

Scriptタグ内のthymeleafが展開されない

こういうやつ

<script>(window.dataLayer || (window.dataLayer = [])).push(<span th:remove="tag" th:utext="${hoge}"/>)</script>

こんな風に修正する

<script type="text/javascript" th:inline="javascript">/*<![CDATA[*/
     (window.dataLayer || (window.dataLayer = [])).push(/*[(${hoge})]*/)
 /*]]>*/</script>

その他

SpringBootでは@EnableWebMvcがついていたら削除する

SessionScope

SpringSecurityの onAuthenticationSuccess 実行時に @SessionScope を参照できなくてエラー
Error creating bean with name 'user': Scope 'session' is not active for the current thread;

以下のようなコンフィグファイルを作成しておいておく

WebRequestContextListener.java
package jp.hoge;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextListener;

import javax.servlet.annotation.WebListener;

@Configuration
@WebListener
public class WebRequestContextListener extends RequestContextListener {
}

Hibernate SaveAndFlushメソッドでエラー

java.sql.SQLSyntaxErrorException: Table 'hoge.hibernate_sequence' doesn't exist
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:536)
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:513)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:115)
    at com.mysql.cj.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:1983)
    at com.mysql.cj.jdbc.PreparedStatement.executeInternal(PreparedStatement.java:1826)
    at com.mysql.cj.jdbc.PreparedStatement.executeQuery(PreparedStatement.java:1923)
    at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52)

GenerationType を変更する

-    @GeneratedValue(strategy=GenerationType.AUTO)
+    @GeneratedValue(strategy=GenerationType.IDENTITY)

実行中にHibernateエラー

org.springframework.dao.InvalidDataAccessResourceUsageException: error performing isolated work; SQL [n/a]; nested exception is org.hibernate.exception.SQLGrammarException: error performing
 isolated work
        at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:242)
        at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:225)
        at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:527)
        at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
        at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
        at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:153)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185)
        at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:135)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185)
        at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:92)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185)
        at org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor.invoke(SurroundingTransactionDetectorMethodInterceptor.java:61)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185)
        at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:212)
        at com.sun.proxy.$Proxy127.save(Unknown Source)
        at jp.hoge.service.FugaService.execute(FugaService.java:218)
        at jp.hoge.controller.FugaController.execute(FugaController.java:101)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)

HibernateのGenerator mappingsが変更になっている。

application.propertiesに以下の設定を追加することで対処

application.properties
spring.jpa.hibernate.use-new-id-generator-mappings=false

STGなどへのデプロイ後

プロファイルの読み込みエラー

logback-spring.xml<springProfile> でプロファイルが正しく読み込めずにエラー
executableJarの場合、-Dspring-boot.run.profiles=環境名 のように定義すればいい。

今回のプロジェクトではtomcatにwarをデプロイするので、
application.propertiesspring-boot.run.profiles=環境名 みたいに定義してやる。

実際のところは、pom.xmlでwarファイル生成時にプロファイルを分けているので、以下のように定義しています。

application.properties
spring-boot.run.profiles=${spring.profiles.active}
pom.xml
<profiles>
        <profile>
            <id>local</id>
            <properties>
                <spring.profiles.active>local</spring.profiles.active>
            </properties>
        </profile>
        <profile>
            <id>stg</id>
            <properties>
                <spring.profiles.active>stg</spring.profiles.active>
            </properties>
        </profile>
</profiles>

ビルド時にプロファイル

mvn package -Pstg

という感じで、 application.properties に埋め込んでいる。

SpringSecurity経由でのログイン後にSession取得後のUserオブジェクトのメンバ変数が全てnullになっている

Userのメンバ変数にアクセスしようとしてNullPointerExepotionが発生
とはいえ、Redisの中身を見てもシリアライズされているため読めない・・・

※Memberは @SessionScope

一旦、以下のような感じでJSONに変換してRedisに登録してくれるSerializerを作成する

HttpSessionConfig.java
@ConditionalOnProperty(name = "spring.session.store-type", havingValue = "redis", matchIfMissing = false)
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 20 * 24 * 60 * 60) //default=1800 -> 20days
@Configuration
public class HttpSessionConfig implements BeanClassLoaderAware {

   @Autowired
   private LettuceConnectionFactory lettuceConnectionFactory;

   private ClassLoader classLoader;

   @Bean
   public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
    final ObjectMapper mapper = new ObjectMapper()
            .registerModules(SecurityJackson2Modules.getModules(classLoader))
            .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
    return new GenericJackson2JsonRedisSerializer(mapper);
  }

      // Elasticache用
      @Bean
      public static ConfigureRedisAction configureRedisAction() {
          return ConfigureRedisAction.NO_OP;
      }

    @Override
    public void setBeanClassLoader(final ClassLoader classLoader) {
        this.classLoader = classLoader;
    }
}

そうするとlogin完了後からControllerに遷移する際に、全フィールドnullの状態でRedisにセットされていた。

ログイン後のControllerで User オブジェクト内の idがnullかどうか判定し、nullであれば詰め直すように対応。
ログイン以降は問題なくセッションを扱えている。

本当はJSON形式でセッション情報を持てるようにしたかったものの、Userクラスの構造が複雑なネスト構造になっていたため、時間との兼ね合いで断念・・・
Userクラスに情報をもたせすぎ・・・・

本番デプロイ時の対応

SpringSession のバージョンアップに伴い、内部的にシリアライズ・デシリアライズで扱う SerialVersionUID が変わっているらしいので、バージョンアップしたコードをデプロイすると、既にログイン中のユーザーはセッション情報をデシリアライズできなくて詰む。

なので、デシリアライズするときの対応を入れる。

https://sdqali.in/blog/2016/11/02/handling-deserialization-errors-in-spring-redis-sessions/

少し記事が古く、依存ライブラリが変更になっているので、以下の通り変更している。

HttpSessionConfig.java
- public class HttpSessionConfig {
+ public class HttpSessionConfig extends RedisHttpSessionConfiguration {

+   @Autowired
+   RedisTemplate<Object, Object> redisTemplate;
+   @Bean
+   @Override
+   public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(SessionRepository<S> sessionRepository) {
+       return super.springSessionRepositoryFilter(new SafeDeserializationRepository<>(sessionRepository, redisTemplate));
+   }
SafeDeserializationRepository.java
public class SafeDeserializationRepository<S extends Session> implements SessionRepository<S> {
    private final SessionRepository<S> delegate;
    private final RedisTemplate<Object, Object> redisTemplate;
     private static final String BOUNDED_HASH_KEY_PREFIX = "spring:session:sessions:";
     public SafeDeserializationRepository(SessionRepository<S> delegate,
            RedisTemplate<Object, Object> redisTemplate) {
        this.delegate = delegate;
        this.redisTemplate = redisTemplate;
    }
     @Override
    public S createSession() {
        return delegate.createSession();
    }
     @Override
    public void save(S session) {
        delegate.save(session);
    }
     @Override
    public S findById(String id) {
        try {
            return delegate.findById(id);
        } catch(SerializationException e) {
            log.info("Deleting non-deserializable session with key {}", id);
            redisTemplate.delete(BOUNDED_HASH_KEY_PREFIX + id);
            return null;
        }
    }
     @Override
    public void deleteById(String id) {
        delegate.deleteById(id);
    }
}

これで既存のユーザーは、一度ログアウトさせられ、再ログインで新しいSessionデータをRedisに保存し、以降はそちらを参照する。

まとめ

大体、上記の対応で本番環境で動作するようになりました。
(他にもある気がするけど)

また、本来であればTomcatは8.5系以上にしなければならないのですが、8.0で問題なさそうであったため、Tomcatのバージョンアップは見送りました。

SpringBootのメジャーバージョンをいくつか飛ばしてしまうと、とてもつらいのでバージョンアップはこまめにやろう・・・!!

参考ページ