关于Spring Boot 2.x升3.x的那些事

序言

手头上有个项目,准备从Spring Boot 2.x升级到3.x,升级后发现编译器报了一堆错误。一般来说大版本升级,肯定会有诸多问题,对于程序开发来说能不升就不升。但是对于系统架构来说,能用最新的肯定是用最新的,实在不行再降回去嘛。可是呢,不知道是发布没多久,还是我搜索技巧的问题,很多问题在网上找不到答案。没办法,还是得自己研究,所以呢这次我们就一起来研究一下Spring Boot 3.x究竟有什么改变。

一、关于Spring Session

一般来说,如果一个Spring Boot 2.x项目一开始只需要单实例部署,用不上redis共享会话的话,会在application.properties里加上这个参数。

spring.session.store-type=none

当需要改为多实例部署,需要redis共享会话的时候,只需要改为这样就行了。

spring.session.store-type=redis

但是在Spring Boot 3.x项目里,这个参数就不复存在了。查了Spring Session的官方文档也没有收获。于是去翻Spring Boot的官方文档,在2.x的参考文档中有这么一条提示“You can disable Spring Session by setting the store-type to none.”。而在3.x的文档中,这个提示被删掉了。好家伙,原来store-type=none是直接禁用整个Spring Session,而不是Api文档中所说的"No session data-store."
那么解决办法就很简单了,单实例部署,不需要用redis的时候,删掉pom.xml里org.springframework.session的依赖就好。需要redis共享会话的时候就要把依赖加回去了,就是没有原来修改配置文件来得方便而已。

二、关于redis

在application.properties里关于redis的配置也有所变化。如果你是这么配置redis的:

spring.redis.host=127.0.0.1
spring.redis.port=6379

这时编译器就会警告你:“Property ‘spring.redis.host’ is Deprecated: Use ‘spring.data.redis.host’ instead.”、“Property ‘spring.redis.password’ is Deprecated: Use ‘spring.data.redis.password’ instead.”按照警告所说的,把“spring.redis”替换成“spring.data.redis”即可

spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379

三、关于servlet

由于tomcat 10包名的更换,如果你的程序是这么写的:

java">import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
...

那么编译器就会报"The import javax.servlet cannot be resolved"错误。原因是包名从javax.servlet 调整为了jakarta.servlet 。解决办法很简单,把javax.servlet 替换为 jakarta.servlet 即可

import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
...

四、关于thymeleaf模板

当你使用片段表达式(fragment expression)而没有使用“~{}”时,会获得运行警告。例如你模板里这么写:

<footer th:replace="footer::copy"></footer>

则会得到这样的运行警告:“Deprecated unwrapped fragment expression “footer::copy” found in template index, line 7, col 9. Please use the ***plete syntax of fragment expressions instead (“~{footer::copy}”). The old, unwrapped syntax for fragment expressions will be removed in future versions of Thymeleaf.”
原因是在thymeleaf 3.1中,未封装的片段表达式不再被推荐。解决方法也很简单,按照警告所说的改为完整版的片段表达式,即加上“~{}”即可

<footer th:replace="~{footer::copy}"></footer>

五、关于Spring Security

重点来了,随着Spring Boot升级到3.x,Spring Security也升级到了6.x。话不多说,先来看看代码,在6.x之前,如果你想要实现动态权限,你的代码可能会是这样的:

@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Autowired
	MyUserService myUserService;
    @Autowired
    MyUrlFilter myUrlFilter;
    @Autowired
    MyDecisionManager myDecisionManager;
    
	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
	protected SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}
    
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
    	auth.userDetailsService(myUserService);
    }
    
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/static/**");
    }
    
    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.apply(new UrlAuthorizationConfigurer<>(http.getSharedObject(ApplicationContext.class)))
            .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                @Override
                public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                    o.setA***essDecisionManager(myDecisionManager);
                    o.setSecurityMetadataSource(myUrlFilter);
                    return o;
                }
            })
            .and().formLogin().loginProcessingUrl("/login/process").loginPage("/login/page")
            .and().logout().logoutUrl("/logout/page")
            .and().sessionManagement().maximumSessions(-1).expiredUrl("/login/page").sessionRegistry(sessionRegistry())
            .and().and().csrf().disable();
    }
}

如果要把上面的代码改成可以在Spring Security 6.x里运行,那么你需要这么写:

@Configuration
public class MySecurityConfig {
	
    @Autowired
    MyAuthorizationManager myAuthorizationManager;
    
	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
	protected SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
    	return web -> web.ignoring().requestMatchers("/static/**");
    }
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.authorizeHttpRequests(authz -> authz.anyRequest().a***ess(myAuthorizationManager))
            .formLogin(login -> login.loginProcessingUrl("/login/process").loginPage("/login/page").permitAll())
            .logout(logout -> logout.logoutUrl("/logout/page").permitAll())
            .sessionManagement(session -> session.maximumSessions(-1).expiredUrl("/login/page").sessionRegistry(sessionRegistry()))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
}

我们来逐个讲解一下。

1.关于WebSecurityConfigurerAdapter

在Spring Security 6.x之前,我们通常是写一个配置类,继承WebSecurityConfigurerAdapter 然后重写(@Override)对应的方法来完成Security的配置的。而在Spring Security 6.x里WebSecurityConfigurerAdapter 已经被弃用了,现在推荐使用的是基于组件的编码方式,只要在配置类里注册对应的组件(@Bean)即可。另外,使用组件配置时and()方法已经不再推荐使用,官方建议使用lambda DSL

2.关于UserDetailsService

按上面所说的,下面这段代码。

	@Autowired
	MyUserService myUserService;
	
	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    	auth.userDetailsService(myUserService);
    }

理论上是要改成这样的。

 	@Autowired
	MyUserService myUserService;
	
	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(myUserService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

但实际上只要你的用户服务(MyUserService)实现了UserDetailsService接口,并且注册到了Spring容器中(加了@Service或者@***ponent注解),Spring Security 6.x就会自动绑定用户服务,只需注册密码加密组件即可。所以上面的代码直接改成下面的就可以了。

	@Bean
	protected PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

(由于篇幅关系,这里就不贴MyUserService的代码了,自己按实际情况实现对应接口功能就好)

3.关于WebSecurity

WebSecurity可以控制哪些地址不进入Security过滤器链。原来的代码是这么写的。

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/static/**");
    }

现在,除了需要改为基于组件的写法外,antMatchers()方法也改成了requestMatchers()方法

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
    	return web -> web.ignoring().requestMatchers("/static/**");
    }

4.关于HttpSecurity

原来在HttpSecurity中实现动态权限,是先要写一个访问地址过滤器(MyUrlFilter),来判断当前访问地址需要什么权限,然后将所需权限送给决策管理器(MyDecisionManager)进行判断是否有权限。

    @Autowired
    MyUrlFilter myUrlFilter;
    @Autowired
    MyDecisionManager myDecisionManager;
    
	@Bean
	protected SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}
	
    @Override
    public void configure(HttpSecurity http) throws Exception{
        http.apply(new UrlAuthorizationConfigurer<>(http.getSharedObject(ApplicationContext.class)))
            .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                @Override
                public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                    o.setA***essDecisionManager(myDecisionManager);
                    o.setSecurityMetadataSource(myUrlFilter);
                    return o;
                }
            })
            .and().formLogin().loginProcessingUrl("/login/process").loginPage("/login/page")
            .and().logout().logoutUrl("/logout/page")
            .and().sessionManagement().maximumSessions(-1).expiredUrl("/login/page").sessionRegistry(sessionRegistry())
            .and().and().csrf().disable();
    }

MyUrlFilter.java

@***ponent
public class MyUrlFilter implements FilterInvocationSecurityMetadataSource {
		
    @Autowired
    A***essPermitService a***essPermitService;

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) object).getRequestUrl();
        //若当前页面是登录页面,则直接放行,否则会进入死循环,最终报重定向次数过多的错误
        if(antPathMatcher.match("/login/**", requestUrl)) {
        	//权限数量为0时,不会调用A***essDecisionManager.decide()方法,无需登录,直接放行
            return SecurityConfig.createList(new String[0]);
        }
        
        //基于数据库的动态权限,获取整个系统的访问路径权限配置(建议缓存起来)
        List<A***essPermit> a***essPermits = a***essPermitService.list();
		
        //遍历访问路径权限配置列表,判断当前请求url和哪个访问路径配置匹配
        for (A***essPermit a***essPermit : a***essPermits) {
            //如果匹配上了,获取这个访问路径的角色
            if(antPathMatcher.match(a***essPermit.getPattern(), requestUrl)){
                String roles = a***essPermit.getRoles();
                //如果没有设置角色,则视为需要登录但不需要对应权限,设置一个默认权限给该访问地址;否则根据逗号切分,返回对应的权限
                if(roles.equals("")) {
                	return SecurityConfig.createList("login_required");
                }
                else{
                    return SecurityConfig.createList(roles.split(","));
                }
            }
        }
        //没有匹配上,则视为需要登录但不需要对应权限,设置一个默认权限给该访问地址
        return SecurityConfig.createList("login_required");
    }
    
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

MyDecisionManager.java

@***ponent
public class MyDecisionManager implements A***essDecisionManager {

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws A***essDeniedException, InsufficientAuthenticationException {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (ConfigAttribute configAttribute : configAttributes) {
        	//需要登录但不需要权限时,myUrlFilter过滤器默认返回一个默认权限,需要进行特殊处理
        	if(configAttribute.getAttribute().equals("login_required")) {
        		//如果没有登录,则返回登录页面;否则用户已登录,直接放行
	        	if (authentication instanceof AnonymousAuthenticationToken) {
	        		throw new A***essDeniedException("没有登录,请登录!");
	        	}
	        	else {
	        		return;
	        	}
        	}
        	//需要权限的情况
        	for (GrantedAuthority authority : authorities) {
        		//判断当前用户是否有对应权限,有则放行
                if(configAttribute.getAttribute().equals(authority.getAuthority())){
                    return;
                }
            }
        }
        //没有权限则不放行
        throw new A***essDeniedException("权限不足,无法访问!");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

LogoutController.java

@Controller
@RequestMapping("/logout")
public class LogoutController {

	@Autowired
	SessionRegistry sessionRegistry;
	
	@RequestMapping("/page")
	public String page(HttpSession session) {
		SessionInformation sessionInformation = sessionRegistry.getSessionInformation(session.getId());
		sessionInformation.expireNow();
		return "redirect:login?logout";
	}
}

现在改成这样:


    @Autowired
    MyAuthorizationManager myAuthorizationManager;
	
	@Bean
	protected SessionRegistry sessionRegistry() {
		return new SessionRegistryImpl();
	}

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.authorizeHttpRequests(authz -> authz.anyRequest().a***ess(myAuthorizationManager))
            .formLogin(login -> login.loginProcessingUrl("/login/process").loginPage("/login/page").permitAll())
            .logout(logout -> logout.logoutUrl("/logout/page").permitAll())
            .sessionManagement(session -> session.maximumSessions(-1).expiredUrl("/login/page").sessionRegistry(sessionRegistry()))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

MyAuthorizationManager.java

@***ponent
public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    @Autowired
    A***essPermitService a***essPermitService;

    private AntPathMatcher antPathMatcher = new AntPathMatcher();

	@Override
	public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {

        String requestUrl = context.getRequest().getServletPath();
		Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();
		
        //基于数据库的动态权限,获取整个系统的访问路径权限配置(建议缓存起来)
        List<A***essPermit> a***essPermits = a***essPermitService.list();
		
        //遍历访问路径权限配置列表,判断当前请求url和哪个访问路径配置匹配
        for (A***essPermit a***essPermit : a***essPermits) {
            //如果匹配上了,获取这个访问路径的角色
            if(antPathMatcher.match(a***essPermit.getPattern(), requestUrl)){
                String roles = a***essPermit.getRoles();
                //如果没有设置角色,则视为需要登录但不需要对应权限;否则根据逗号切分,返回对应的权限
                if(roles.equals("")) {
                	break;
                }
                else{
                    for(String role : roles.split(",")) {
                    	for (GrantedAuthority authority : authorities) {
                    		//判断当前用户是否有对应权限,有则放行
                            if(role.equals(authority.getAuthority())){
                            	return new AuthorizationDecision(true);
                            }
                        }
                    }
                    return new AuthorizationDecision(false);
                }
            }
        }

    	if (authentication.get() instanceof AnonymousAuthenticationToken) {
            return new AuthorizationDecision(false);
    	}
    	else return new AuthorizationDecision(true);
	}
}

这里改动挺多的,一是使用lambda DSL的格式去写相关代码。二是现在只需要使用authorizeHttpRequests()方法配置一个自定义的授权管理器(MyAuthorizationManager)就可以了。可以理解为这个授权管理器(MyAuthorizationManager)取代了原来的访问地址过滤器(MyUrlFilter)和决策管理器(MyDecisionManager)。三是现在的过滤器链是先经过HttpSecurity 的过滤器再到授权管理器(MyAuthorizationManager)的,之前给登录页面放行的相关逻辑也不用自己实现了,但是formLogin和logout都要设置.permitAll()。四是logoutUrl(“/logout/page”)无需自行实现了,这个页面与loginProcessingUrl(“/login/process”)一样,已经交由Security 托管了,自行实现也不会执行。五是现在sessionRegistry会自动销毁登出的会话了,也无需自行实现了。
(由于篇幅关系,这里就不贴A***essPermitService 的相关代码了,大家按自己实际情况去实现即可)

总结

从Spring Boot 2.x升级到3.x肯定还有很多改动,是我这里没列举的,虽然改动挺多的,但还是建议能用最新的版本就用最新的版本。特别是Spring Security 升级到了6.x之后,代码逻辑清晰了许多,不会像之前那样绕到云里雾里,仅这点就值得了。

参考资料
Spring Session #2.7.15-SNAPSHOT
Spring Session #3.0.10-SNAPSHOT
Thymeleaf #3.0
Thymeleaf #3.1
Spring Security without the WebSecurityConfigurerAdapter

转载请说明出处内容投诉
CSS教程_站长资源网 » 关于Spring Boot 2.x升3.x的那些事

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买