The main difference between Spring Boot 2.7 and Spring Boot 3 is the Hibernate 6 update of the Spring Framework. The new namespace is just search and replace. Spring Security needs some updates too. Spring has discontinued the Flapdoodle support.

Jpa / Hibernate updates

Hibernate 6 has more checks for the JpaQl queries and the entities. There are also more runtime checks for entity relations.

The default naming convention for the database sequences has changed. The new configuration properties can be found in the property files of the AngularPortfolioMgr project:

spring.jpa.properties.hibernate.id.db_structure_naming_strategy=legacy

Spring Security

Spring Security has changed how the HttpSecurity setup works. The configuration has to be provided in a ‘SecurityFilterChain’ and the ‘antMatchers’ need to be wrapped in ‘requestMatchers’. The RequestMatchers need to contain the authorizations. A new security configuration can be found in the WebSecurityConfig class of the AngularPortfolioMgr project:

@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
  JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenService);
  final String blockedPath = this.activeProfile.toLowerCase()
    .contains("prod") ? DEVPATH : PRODPATH;
  HttpSecurity httpSecurity = http.authorizeHttpRequests(authorize -> 
    authorize.requestMatchers(
      AntPathRequestMatcher.antMatcher("/rest/config/**")).permitAll()
      .requestMatchers(
        AntPathRequestMatcher.antMatcher("/rest/kedatest/**")).permitAll()
      .requestMatchers(
        AntPathRequestMatcher.antMatcher("/rest/auth/**")).permitAll()
      .requestMatchers(AntPathRequestMatcher.antMatcher("/rest/**"))
        .hasAuthority(DataHelper.Role.USERS.toString())
      .requestMatchers(
        AntPathRequestMatcher.antMatcher(blockedPath)).denyAll())
      .authorizeHttpRequests(authorize -> authorize.requestMatchers(
        AntPathRequestMatcher.antMatcher("/**")).permitAll())
      .csrf(myCsrf -> myCsrf.disable())
      .sessionManagement(mySm -> 
         mySm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .headers(myHeaders -> myHeaders.contentSecurityPolicy(myCsp ->
         myCsp.policyDirectives("default-src 'self'; 
           script-src 'self' 'unsafe-inline'; 
           style-src 'self' 'unsafe-inline';")))
      .headers(myHeaders -> myHeaders.xssProtection(myXss -> 
        myXss.headerValue(HeaderValue.ENABLED)))
      .headers(myHeaders -> myHeaders.frameOptions(myFo -> 
        myFo.sameOrigin()))
      .addFilterBefore(customFilter, 
        UsernamePasswordAuthenticationFilter.class);
  return httpSecurity.build();
}

Liquibase parameter

The new Liquibase version checks for dublicate migration files. The dublications can happen with the development builds or at the AOT build step that speeds up application startup. Because of that the application needs this VM Parameter at startup ‘-Dliquibase.duplicateFileMode=WARN’. The Dockerfile has been updated.

Spring support for flapdoodle has ended

The Spring support for flapdoodle has ended. The flapdoodle subproject de.flapdoodle.embed.mongo.spring with the branch spring-3.0.x now provides the spring boot 3 support. It has been added to the maven pom.xml:

...
<dependency>
	<groupId>de.flapdoodle.embed</groupId>
	<artifactId>de.flapdoodle.embed.mongo</artifactId>
	<version>4.5.1</version>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>de.flapdoodle.embed</groupId>
	<artifactId>de.flapdoodle.embed.mongo.spring30x</artifactId>
	<version>4.5.2</version>
	<scope>test</scope>
</dependency>
...
<profiles>
	<profile>
		<id>standalone</id>
		<activation>
			<property>
				<name>docker</name>
				<value>!true</value>
			</property>
		</activation>
		<dependencies>
			<dependency>
				<groupId>de.flapdoodle.embed</groupId>
				<artifactId>de.flapdoodle.embed.mongo</artifactId>
				<version>4.5.1</version>
			</dependency>
			<dependency>
				<groupId>de.flapdoodle.embed</groupId>
				<artifactId>de.flapdoodle.embed.mongo.spring30x</artifactId>
				<version>4.5.2</version>
			</dependency>
		</dependencies>
	</profile>
...

To use the embedded MongoDb the SpringMongoConfig.java file needed to be updated:

public SpringMongoConfig(MongoMappingContext mongoMappingContext, MongoProperties mongoProperties) {
   this.mongoMappingContext = mongoMappingContext;
   this.mongoProperties = mongoProperties;
 }

@Bean
public MongoClient mongoClient() {
	return MongoClients.create(this.mongoUri.replace("27027",
		this.mongoProperties.getPort() == null ? "27027" : this.mongoProperties.getPort().toString()));
}

The embedded MongoDb is started on a random port to to get the random port the ‘MongoProperties’ are needed. They are injected in the constructor and used in the ‘mongoClient()’ method. The ‘mongoUri’ contains the default port ‘27027’ that is replaced with the port of the ‘MongoProperties’ if it exists.

The properties have been updated:

...
spring.data.mongodb.uri=mongodb://${MONGODB_HOST:localhost}:27027/messenger?compressors=zlib
de.flapdoodle.mongodb.embedded.version=4.4.18
...

The ‘spring.data.mongo.uri’ sets the MongoDb hostname according to the environment variable ‘MONGODB_HOST’ or sets the default ‘localhost’. The default port ‘27027’ is added to be easily replaced if the embedded MongoDb is used. The property ‘de.flapdoodle.mongodb.embedded.version’ is used to provide the version of MongoDb to flapdoodle.

Controller testing

The ‘@WebMvcTest’ controller tests needed an update. The tests now run the security filters and all the used services needed to be mocked. The ‘servletPath’ is needed by my filters and now the complete path is needed. The BaseControllerTest class contains the new mocked resources. The PortfolioControllerTest is an example:

@WebMvcTest(PortfolioController.class)
@ComponentScan(basePackages = "ch.xxx.manager",excludeFilters = 
@Filter(type = FilterType.CUSTOM, classes = ComponentScanCustomFilter.class))
public class PortfolioControllerTest extends BaseControllerTest {
	private static final String TEST_SECRECT_KEY = "l4v46cegVyPzuqCPs2bZw1egItei_5n-
           FrZChxcg8iYVZcEs6_2TbvtlYVtmuheU77O4AurSah3JCAyfuapG"
	+ "CRSLpttN9dMqam85wSRjhoKDz-_QWAjbUMptwFlskNa_8vZ-DvwwnkcvbEfBSvVJSUt8_4ZrWpBq1tX56PTOobbI-  
           oXasUkmeYdD2tLDvErmPXC"
	+ "ntTSqGB7c4jcoPT3IX1mUsNZp5hYPUWpZjXDSmx2Os1JhY2ezTJJBpMq0o559aSJPs1rkqH1zEFrYDs41-
           mFTujaIrxv4iC8wGsXqvixamg9mC0P8n"
	+ "645McBJ6Q3X0PElFGbF6gmKtvrOqpQHA==";
	@MockBean
	private PortfolioService portfolioService;
	@MockBean
	private PortfolioMapper portfolioMapper;
	@MockBean
	private JwtTokenService jwtTokenService;
	@Autowired
	private MockMvc mockMvc;

	@SuppressWarnings("unchecked")
	@BeforeEach
	public void init() {
		Mockito.when(this.portfolioMapper.toDto(any(Portfolio.class))).thenCallRealMethod();
		Mockito.when(this.jwtTokenService.createToken(any(String.class), any(List.class), 
                   any(Optional.class))).thenCallRealMethod();
		Mockito.when(this.jwtTokenService.resolveToken(any(HttpServletRequest.class))).thenReturn("");
		Mockito.when(this.jwtTokenService.validateToken(any(String.class))).thenReturn(true);
		Mockito.when(this.jwtTokenService.getAuthentication(any(String.class))).thenCallRealMethod();
		Mockito.when(this.jwtTokenService.getTokenUserRoles(any(Map.class))).thenCallRealMethod();
		Mockito.when(this.jwtTokenService.getUsername(any(String.class))).thenReturn("XXX");		                                  
                Mockito.when(this.jwtTokenService.getAuthorities(any(String.class)))
                   .thenReturn(List.of(DataHelper.Role.USERS));
		Mockito.when(this.portfolioService.getPortfolioById(any(Long.class)))
                   .thenReturn(this.createPortfolioEntity());
		ReflectionTestUtils.setField(this.jwtTokenService, "secretKey", TEST_SECRECT_KEY);
		ReflectionTestUtils.setField(this.jwtTokenService, "validityInMilliseconds", 60000);
		Mockito.doCallRealMethod().when(this.jwtTokenService).init();
		this.jwtTokenService.init();
	}

	@Test
	public void findPortfolioByIdNotFound() throws Exception {
		String myToken = this.jwtTokenService.createToken("XXX", List.of(Role.USERS), Optional.empty());
		Portfolio myPortfolio = createPortfolioEntity();
		Mockito.when(this.portfolioService.getPortfolioById(any(Long.class))).thenReturn(myPortfolio);
		this.mockMvc.perform(get("/rest/portfolio/id/{portfolioId}", 1L)
		    .header(JwtUtils.AUTHORIZATION, String.format("Bearer %s", 
                        myToken)).servletPath("/rest/portfolio/id/1"))
		.andExpect(status().isOk())
		.andExpect(jsonPath("$.id").value(1))
		.andExpect(jsonPath("$.name").value("MyPortfolio"));
	}

	private Portfolio createPortfolioEntity() {
		Portfolio myPortfolio = new Portfolio();
		ReflectionTestUtils.setField(myPortfolio, "createdAt", LocalDate.now());
		myPortfolio.setId(1L);
		myPortfolio.setName("MyPortfolio");
		myPortfolio.setAppUser(new AppUser());
		return myPortfolio;
	}
}

Hibernate Search

To use Hibernate Search with Spring Boot 3 Hibernate Search 6.2 is used. The Api has changed and been simplified. The Code has been rewritten and now looks like this:

public List<Movie> findMoviesByPhrase(SearchPhraseDto searchPhraseDto) {
  List<Movie> resultList = List.of();
  if (searchPhraseDto.getPhrase() != null && 
    searchPhraseDto.getPhrase().trim().length() > 2) {
    resultList = Search.session(this.entityManager)
      .search(Movie.class).where(f -> f.phrase().field("overview")
      .matching(searchPhraseDto.getPhrase())
      .slop(searchPhraseDto.getOtherWordsInPhrase()))
      .fetchHits(1000);
  }
  return resultList;
}

public List<Movie> findMoviesBySearchStrings(List<SearchStringDto>   
  searchStrings) {
  StringBuilder stringBuilder = new StringBuilder();
  searchStrings.stream().filter(searchStringDto ->   
      searchStringDto.getOperator() != null 
      && searchStringDto.getSearchString() != null)
    .toList().forEach(myDto -> 
      stringBuilder.append(" ").append(myDto.getOperator().value).append(" ")
      .append(myDto.getSearchString()));
  List<Movie> resultList =   
    Search.session(this.entityManager).search(Movie.class)
      .where(f -> f.simpleQueryString().field("overview")
        .matching(stringBuilder.substring(2)))
	.fetchHits(1000);
    return resultList;
}

The features used are the same. The api is much simpler and the more readable code was well worth the rewrite. The effort to rewrite the code was limited.

Spring Aot

Spring Aot creates Metadata for the creation of native images. It can be used to make Spring Boot applications start faster on the Jvm. During development the application needs to be rebuild if the injected apis have changed. Enabling it is low hanging fruit:

Gradle:

 plugins {
	id 'java'
	id 'org.springframework.boot' version '3.0.0'
	id 'org.graalvm.buildtools.native' version '0.9.18'
	id 'io.spring.dependency-management' version '1.1.0'
}

Adding the Graalvm build tools switches the aot phase on.

Maven:

<plugin>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-maven-plugin</artifactId>
	<executions>
		...
		<execution>
			<id>process-aot</id>
			<goals>
				<goal>process-aot</goal>
			</goals>
		</execution>
	</executions>
</plugin>

This adds the aot phase in the spring boot plugin.

Arch Unit Tests:

static final class DoNotIncludeAotGenerated implements ImportOption {
	private static final Pattern AOT_GENERATED_PATTERN = Pattern
		.compile(".*(__BeanDefinitions|SpringCGLIB\\$\\$0)\\.class$");
	private static final Pattern AOT_TEST_GENERATED_PATTERN = Pattern
		.compile(".*__TestContext.*\\.class$");

	@Override
	public boolean includes(Location location) {
		return !(location.matches(AOT_GENERATED_PATTERN) || location.matches(AOT_TEST_GENERATED_PATTERN));
	}
}

This inner class is used to filter out the generated classes of the aot phase. It is added as importOption to the importedClasses. A example can be found in MyArchitecturTests.

Conclusion

The Migration should not be underestimated. A medium sized project can take some time to upgrade and needs to be tested.