Spring Boot 2.x升3.x的那些事
作者:arbiterlk
序言
手头上有个项目,准备从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包名的更换,如果你的程序是这么写的:
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 complete 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.setAccessDecisionManager(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().access(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或者@Component注解),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.setAccessDecisionManager(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
@Component public class MyUrlFilter implements FilterInvocationSecurityMetadataSource { @Autowired AccessPermitService accessPermitService; private AntPathMatcher antPathMatcher = new AntPathMatcher(); public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { String requestUrl = ((FilterInvocation) object).getRequestUrl(); //若当前页面是登录页面,则直接放行,否则会进入死循环,最终报重定向次数过多的错误 if(antPathMatcher.match("/login/**", requestUrl)) { //权限数量为0时,不会调用AccessDecisionManager.decide()方法,无需登录,直接放行 return SecurityConfig.createList(new String[0]); } //基于数据库的动态权限,获取整个系统的访问路径权限配置(建议缓存起来) List<AccessPermit> accessPermits = accessPermitService.list(); //遍历访问路径权限配置列表,判断当前请求url和哪个访问路径配置匹配 for (AccessPermit accessPermit : accessPermits) { //如果匹配上了,获取这个访问路径的角色 if(antPathMatcher.match(accessPermit.getPattern(), requestUrl)){ String roles = accessPermit.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
@Component public class MyDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); for (ConfigAttribute configAttribute : configAttributes) { //需要登录但不需要权限时,myUrlFilter过滤器默认返回一个默认权限,需要进行特殊处理 if(configAttribute.getAttribute().equals("login_required")) { //如果没有登录,则返回登录页面;否则用户已登录,直接放行 if (authentication instanceof AnonymousAuthenticationToken) { throw new AccessDeniedException("没有登录,请登录!"); } else { return; } } //需要权限的情况 for (GrantedAuthority authority : authorities) { //判断当前用户是否有对应权限,有则放行 if(configAttribute.getAttribute().equals(authority.getAuthority())){ return; } } } //没有权限则不放行 throw new AccessDeniedException("权限不足,无法访问!"); } @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().access(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
@Component public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> { @Autowired AccessPermitService accessPermitService; 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<AccessPermit> accessPermits = accessPermitService.list(); //遍历访问路径权限配置列表,判断当前请求url和哪个访问路径配置匹配 for (AccessPermit accessPermit : accessPermits) { //如果匹配上了,获取这个访问路径的角色 if(antPathMatcher.match(accessPermit.getPattern(), requestUrl)){ String roles = accessPermit.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会自动销毁登出的会话了,也无需自行实现了。
(由于篇幅关系,这里就不贴AccessPermitService 的相关代码了,大家按自己实际情况去实现即可)
总结
从Spring Boot 2.x升级到3.x肯定还有很多改动,是我这里没列举的,虽然改动挺多的,但还是建议能用最新的版本就用最新的版本。特别是Spring Security 升级到了6.x之后,代码逻辑清晰了许多,不会像之前那样绕到云里雾里,仅这点就值得了。
参考资料
Spring Session #2.7.15-SNAPSHOT
Spring Session #3.0.10-SNAPSHOT
Spring Security without the WebSecurityConfigurerAdapter
到此这篇关于Spring Boot 2.x升3.x的那些事的文章就介绍到这了,更多相关Spring Boot 2.x升3.x的那些事内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!