序言
手头上有个项目,准备从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