Spring Boot 4.1.1 深度源码解析-第 31 章 · 安全与全景收官:Spring Security 自动配置与整套回顾
本系列最后一章。前半部分把
spring-boot-security的九个自动配置类逐个拆开——它是本系列里 唯一一个横跨 Servlet / Reactive / RSocket 三种运行时、同时又深入 Actuator 子上下文的模块, 因此天然适合作为"把前面三十章的机制串起来"的收官案例。后半部分做全系列回顾: 把散落在三十章里的定律按主题重排成一张地图,并给出可直接执行的代码级自测清单。
0. 为什么把安全放在最后一章
从第一版目录规划开始,第 31 章就写着"安全与全景收官"。这不是随意的顺序,而是因为 Spring Security 的自动配置没有一项机制是全新的——它用到的每一样东西,前面三十章都讲过:
| 用到的机制 | 出处 |
|---|---|
@AutoConfiguration + AutoConfiguration.imports | 第 3 章、第 14 章 |
@ConditionalOnClass / @ConditionalOnMissingBean / @ConditionalOnWebApplication | 第 15 章 |
AnyNestedCondition / AllNestedConditions / @Conditional 自定义 | 第 15 章 |
@ConditionalOnMissingBean(type = "...") 的字符串类型守卫 | 第 15 章 |
ConfigurationPhase.REGISTER_BEAN 与 PARSE_CONFIGURATION 的差别 | 第 15 章 |
@AutoConfiguration(before = ...) / afterName = ... | 第 14 章 |
ManagementContextConfiguration 子上下文 | 第 25 章 |
WebServerNamespace / ManagementPortType | 第 25 章 |
META-INF/spring/<注解全名>.imports 与 .includes | 第 29 章 |
DelegatingFilterProxyRegistrationBean | 第 18 章 |
所以这一章的正确读法不是"学新东西",而是检查你能否在不看前面章节的情况下,解释每一行代码为什么这么写。 能解释清楚,说明三十章的机制已经内化;解释不清,回看对应的章节。
本章也会刻意用一种"逆向"的写法:先给结论,再回到源码找证据。因为到这个阶段, "看到代码能推出设计意图"比"看到设计意图能找到代码"更重要——后者是查找,前者才是能力。
1. 九个自动配置类:一张全图
spring-boot-security 模块的 AutoConfiguration.imports 只有九行:
org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.actuate.web.reactive.ReactiveManagementWebSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.rsocket.RSocketSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
排序是字母序,不是执行顺序——这一点第 3 章讲过(CheckFactoriesFile / checkAutoConfigurationImports
强制字母序,理由是避免多人协作产生无意义 diff)。看 imports 文件永远读不出顺序,顺序要看
@AutoConfiguration 的 before / after / afterName。
按运行时分成四组:
| 组 | 类 | 触发条件 | 干什么 |
|---|---|---|---|
| 通用 | SecurityAutoConfiguration | DefaultAuthenticationEventPublisher 存在 | 认证事件发布器 + Spring Data 安全表达式 |
UserDetailsServiceAutoConfiguration | Servlet Web + 无替代认证源 | 内存用户(默认 user + 随机密码) | |
ReactiveUserDetailsServiceAutoConfiguration | Reactive Web 或 RSocket + 无替代认证源 | 同上,响应式版 | |
| Servlet | ServletWebSecurityAutoConfiguration | Servlet Web + EnableWebSecurity 存在 | 默认 SecurityFilterChain + @EnableWebSecurity |
SecurityFilterAutoConfiguration | Servlet Web + 名字为 springSecurityFilterChain 的 bean | 把过滤器注册进容器 | |
ManagementWebSecurityAutoConfiguration | Servlet Web + Actuator | 放行 health 端点 | |
| Reactive | ReactiveWebSecurityAutoConfiguration | Reactive Web | @EnableWebFluxSecurity + denyAll兜底 |
ReactiveManagementWebSecurityAutoConfiguration | Reactive Web + Actuator | 放行 health 端点 | |
| RSocket | RSocketSecurityAutoConfiguration | RSocketServerCustomizer + SecuritySocketAcceptorInterceptor | RSocket 拦截器 |
注意一个细节:SecurityFilterAutoConfiguration 与 ServletWebSecurityAutoConfiguration 是两个类,
而不是一个。 类注释里写明了原因:
Configured separately from
ServletWebSecurityAutoConfigurationto ensure that the filter's order is still configured when a user-providedWebSecurityConfigurationexists.
也就是说:用户可能自己写了 @EnableWebSecurity 配置类(此时 ServletWebSecurityAutoConfiguration
因为 @ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN) 会退让),
但过滤器的顺序仍然需要被配置。把"注册过滤器到容器"和"配置默认安全规则"拆成两个自动配置,
就能让前者在后者退让的情况下照样生效。
这是一个非常干净的**"正交拆分"**:两个关注点(注册 vs 规则)有不同的退让条件, 放在一个类里就只能取交集,拆开后各自独立退让。
1.1 三个"命名不一致"的历史包袱
读这个模块时会被三处命名绊一下,它们都是历史遗留,不是设计:
-
springSecurityFilterChain—— 这是AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME常量的值,是 Spring Security 侧定义的名字。Spring Boot 只是引用它(DEFAULT_FILTER_NAME)。 它指的是FilterChainProxy这个 bean,名字里带着"chain"但类型是 Proxy。 -
UserDetailsServiceAutoConfiguration叫"Service"但注册的是InMemoryUserDetailsManager——InMemoryUserDetailsManager实现了UserDetailsService,名字取的是接口。 -
SecurityFilterProperties.BASIC_AUTH_ORDER与DEFAULT_FILTER_ORDER是两个不同的 order ——前者是SecurityFilterChain在 Spring Security 内部的顺序,后者是过滤器在 Servlet 容器里的顺序。 属性类的 Javadoc 专门警告过:There is no connection between this and the
@Orderon aSecurityFilterChain.这句注释是必需的,因为两个 order 数值相近(
-100与LOWEST_PRECEDENCE - 5), 极易被误认为是同一个东西。
2. SecurityAutoConfiguration:只有两个 bean,但每个都有讲究
@AutoConfiguration
@ConditionalOnClass(DefaultAuthenticationEventPublisher.class)
@EnableConfigurationProperties(SecurityProperties.class)
public final class SecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean(AuthenticationEventPublisher.class)
DefaultAuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher publisher) {
return new DefaultAuthenticationEventPublisher(publisher);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(SecurityEvaluationContextExtension.class)
static class SecurityDataConfiguration {
@Bean
@ConditionalOnMissingBean
SecurityEvaluationContextExtension securityEvaluationContextExtension() {
return new SecurityEvaluationContextExtension();
}
}
}
2.1 为什么 @ConditionalOnClass 选 DefaultAuthenticationEventPublisher
这是个哨兵类选择问题(第 15 章讲过):选哪个类做"Spring Security 是否在 classpath 上"的判据?
候选有很多:AuthenticationManager、SecurityFilterChain、HttpSecurity……
选 DefaultAuthenticationEventPublisher 的理由是它是本自动配置真正要 new 的那个类。
这是第 15 章反复出现的纪律:哨兵类应该选你实际要实例化的类,而不是"最有名"的类。
否则会出现"哨兵存在但要 new 的类不存在"的 NoClassDefFoundError。
同理,内层 SecurityDataConfiguration 的哨兵是 SecurityEvaluationContextExtension——它自己要 new 的类。
这是 Spring Security 与 Spring Data 的集成点(SecurityEvaluationContextExtension 让 SpEL 里能用
hasRole(...)),属于可选依赖,所以单独包一层 @Configuration 并加 @ConditionalOnClass。
嵌套 @Configuration + @ConditionalOnClass 是处理"类内部的可选分支"的标准手法。
第 15 章讲过:不能把 @ConditionalOnClass 加在 @Bean 方法上(那样无效,因为类已经被加载了),
必须加在一个嵌套配置类上——因为嵌套类的加载是延迟的,只有外层类通过条件后才会被 ASM 扫描到。
2.2 authenticationEventPublisher 的参数是 ApplicationEventPublisher
new DefaultAuthenticationEventPublisher(publisher)——把 Spring 的事件发布器传给 Spring Security,
让认证成功/失败事件能进 Spring 的事件体系。
这一行的意义在于:Spring Security 的认证事件从此可以被 @EventListener 监听,
也可以被 Actuator 的审计(AuditEventRepository)消费。这是两个框架之间的一个很小的桥,
但没有它,认证审计就得自己写监听器去转接。
@ConditionalOnMissingBean(AuthenticationEventPublisher.class)——注意条件类型是接口
AuthenticationEventPublisher,而返回类型是实现 DefaultAuthenticationEventPublisher。
这是第 15 章讲过的重要陷阱:@ConditionalOnMissingBean 判断的是方法返回类型的"可赋值性",
但 Spring Boot 会按方法返回类型推导,所以这里实际检查的是"有没有任何 AuthenticationEventPublisher
(含子类)的 bean"。如果用户自己注册了一个自定义实现(哪怕是别的子类),这个 bean 就不注册。
待核实:
@ConditionalOnMissingBean在使用返回类型为实现类时,是否按实现类型还是按 推断出的"最具体可用类型"来匹配,本轮未逐行确认 Boot 4.1 的OnBeanCondition实现。 可自测:注册一个自定义的AuthenticationEventPublisher(非DefaultAuthenticationEventPublisher子类) bean,看DefaultAuthenticationEventPublisher是否仍然注册。
2.3 SecurityProperties:随机密码是在字段初始化时生成的
这是本章最容易被忽略、但每天都见到的一个行为:
@ConfigurationProperties("spring.security")
public class SecurityProperties {
private final User user = new User();
public User getUser() {
return this.user;
}
public static class User {
private String name = "user";
private String password = UUID.randomUUID().toString();
private List<String> roles = new ArrayList<>();
private boolean passwordGenerated = true;
// ...
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
this.passwordGenerated = false;
this.password = password;
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
}
五个要点:
一、密码在字段初始化时生成。 UUID.randomUUID().toString() 是实例字段的初始化表达式,
只要 new User() 就会执行。而 SecurityProperties 是被 @ConfigurationProperties 绑定的 bean,
每次应用启动都会 new 一次,所以每次启动密码都不一样——这就是那个控制台里
"Using generated security password: xxxxx"的由来。
这个设计的巧妙之处在于它不需要任何"是否配置了密码"的判断逻辑:
先生成,再让 setter 覆盖。passwordGenerated 标志位记录"是否被覆盖过"。
二、setPassword 对空字符串 return 而不设值。
if (!StringUtils.hasLength(password)) {
return;
}
这一行防的是:用户在 application.properties 里写了 spring.security.user.password=(空值,
可能是从环境变量来的空串)。如果没有这个守卫,空密码会覆盖随机密码,
导致应用以空密码启动——那是一个严重的安全问题(任何人都能用 user + 空密码登录)。
注意这里用的是 hasLength(长度 > 0),不是 hasText(含非空白字符)。
也就是说 " "(三个空格)会被接受为有效密码。推测这是刻意的:
密码本身可能就是空格组成的,框架不应该替用户判断"什么算有效的密码"。
推测:
hasLength而非hasText是刻意选择,但源码未注释说明。可自测: 设spring.security.user.password=" "(三空格),观察是否生效。
三、passwordGenerated 的语义是"未被覆盖",不是"是随机的"。
这个名字有点误导。它精确的含义是:用户没有通过配置设置过密码。
所以只要 setPassword 被成功调用过一次(非空),它就是 false。
四、setRoles 做了防御性拷贝。
public void setRoles(List<String> roles) {
this.roles = new ArrayList<>(roles);
}
绑定器(第 8 章)传进来的 List 可能是可变的、也可能被复用。拷贝一份避免外部修改影响内部状态。
对比 password 是 String(不可变,无需拷贝),name 也是 String。
"可变类型在 setter 里拷贝"是本仓库的一致纪律,第 9 章讲 @ConfigurationProperties 时总结过。
五、User 是 static 嵌套类,且是 final 字段容器。
private final User user = new User(); 只有 getter 没有 setter——这是嵌套绑定(第 8 章)的标准形态:
外层属性类持有内层实例,绑定器递归绑定内层。不能用构造器注入创建,因为
@ConfigurationProperties 需要默认构造器(除非用构造器绑定)。
2.4 随机密码的警告日志:为什么在 getOrDeducePassword 里打
日志不是在 SecurityProperties 里打的,而是在消费它的 UserDetailsServiceAutoConfiguration 里:
if (user.isPasswordGenerated()) {
logger.warn(String.format(
"%n%nUsing generated security password: %s%n%nThis generated password is for development use only. "
+ "Your security configuration must be updated before running your application in "
+ "production.%n",
user.getPassword()));
}
为什么放在这里?两个理由:
- 属性类不应该打日志。
SecurityProperties是纯数据载体,可能被绑定多次、可能被spring-boot-properties-migrator读取、可能在 AOT 处理时被实例化。在里面打日志会导致重复输出。 - 只有真正用它建用户时才需要警告。 如果用户自己提供了
UserDetailsService(此时UserDetailsServiceAutoConfiguration整个退让),随机密码根本不会被使用, 打警告就是噪音。
这是一个很实用的判断准则:日志应该打在"行为发生的地方",而不是"数据存在的地方"。
注意 String.format 里的 %n%n——前后各两个换行,让这条警告在控制台里被空行包起来非常显眼。
用 %n 而不是 \n 是为了跨平台(Windows 是 \r\n)。这个细节在本仓库的日志输出里反复出现。
对比:
ReactiveUserDetailsServiceAutoConfiguration里同样位置的日志是logger.info且消息只有一行(没有"for development use only"那段)。两个实现的日志级别不一致 (Servlet 侧warn、Reactive 侧info)。推测是历史遗留而非刻意设计。 可自测:分别跑 Servlet 与 Reactive 的空安全应用,对比启动日志的级别。
2.5 随机密码与 AOT / Native 的冲突
UUID.randomUUID() 在字段初始化时求值——这在 AOT 处理时意味着什么?
AOT 处理进程(第 28 章)会真实启动一次应用上下文,SecurityProperties 会被实例化,
于是会生成一个随机密码。但这个密码不会进入任何 AOT 产物——因为
SecurityProperties 是运行期绑定的 @ConfigurationProperties bean,
AOT 只对 bean definition 做快照,不会把属性值写死。
真正进入 native image 的只有 bean 定义:"有一个 SecurityProperties bean,
它有一个 User 子对象"。运行期启动 native 应用时,字段初始化再次执行,生成另一个随机密码。
所以:native 应用每次启动的随机密码也不同,行为与 JVM 一致。这是正确但值得注意的一点—— 如果你看到 native 镜像的启动日志里有一条"Using generated security password", 不要以为是构建时硬编码进去的。
这里还藏着一个 AOT 相关的隐患:isPasswordGenerated() 的判定发生在运行期,
而 spring.security.user.password 如果在构建机上通过命令行参数传入(ProcessAotMojo
的 <arguments>,第 28 章 §8.1),AOT 处理时会看到"密码已配置",
但运行期不传同一个参数的话,密码又会变成随机的。
构建期与运行期的参数不一致问题,在这里的具体表现就是安全配置漂移。
3. UserDetailsServiceAutoConfiguration:默认用户的五个条件
@AutoConfiguration
@ConditionalOnClass(AuthenticationManager.class)
@Conditional(MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured.class)
@ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class,
UserDetailsService.class, AuthenticationManagerResolver.class },
type = "org.springframework.security.oauth2.jwt.JwtDecoder")
@ConditionalOnWebApplication(type = Type.SERVLET)
public final class UserDetailsServiceAutoConfiguration {
private static final String NOOP_PASSWORD_PREFIX = "{noop}";
private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
@Bean
InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(User.withUsername(user.getName())
.password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles))
.build());
}
// ...
}
类上一共四个条件,它们回答的是四个不同的问题,缺一不可:
| 条件 | 回答的问题 |
|---|---|
@ConditionalOnClass(AuthenticationManager.class) | Spring Security 在 classpath 上吗? |
@Conditional(MissingAlternative...) | 有别的认证源吗?或者用户显式配了 user 吗? |
@ConditionalOnMissingBean({...}, type = "JwtDecoder") | 用户自己提供认证组件了吗? |
@ConditionalOnWebApplication(type = SERVLET) | 是 Servlet Web 应用吗? |
3.1 两个 @ConditionalOnMissingBean 的差别:value 与 type
这一行值得单独拆:
@ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class,
UserDetailsService.class, AuthenticationManagerResolver.class },
type = "org.springframework.security.oauth2.jwt.JwtDecoder")
value 里是 Class 对象,type 里是字符串全限定名。
为什么 JwtDecoder 要用字符串?因为 JwtDecoder 属于 spring-security-oauth2-jose,
是可选依赖——spring-boot-security 不依赖它。如果写成 JwtDecoder.class,
那么在没有 OAuth2 依赖的应用里,加载 UserDetailsServiceAutoConfiguration 类本身就会抛
NoClassDefFoundError(注解里的 Class 字面量会被解析)。
这正是第 28 章第七十七定律("可选依赖的守卫必须用字符串类名")的一次完整复现,
只不过那里是 ClassUtils.isPresent(...),这里是 @ConditionalOnMissingBean(type = "...")。
同一种约束,在不同位置有不同写法,但规则一致:只要引用的类可能不存在,就必须用字符串。
这也解释了为什么这个条件判断的是"没有 JwtDecoder":
如果应用里有 JwtDecoder(说明用了 JWT 资源服务器),
那么认证走的是 JWT 而不是内存用户,默认用户就不该创建。
3.2 MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured:三选一的 AnyNestedCondition
final class MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured extends AnyNestedCondition {
MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured() {
super(ConfigurationPhase.PARSE_CONFIGURATION);
}
@ConditionalOnMissingClass({ "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
static final class MissingAlternative {
}
@ConditionalOnProperty("spring.security.user.name")
static final class NameConfigured {
}
@ConditionalOnProperty("spring.security.user.password")
static final class PasswordConfigured {
}
}
AnyNestedCondition 是"任一内部条件匹配即匹配"(第 15 章)。三个分支的语义:
- 没有其他认证源(没有
ClientRegistrationRepository/OpaqueTokenIntrospector/RelyingPartyRegistrationRepository)。这三个类分别代表 OAuth2 登录、不透明令牌、SAML2—— 有任何一个,说明用户走的是企业级认证,不该再创建内存用户。 - 显式配了
spring.security.user.name。 - 显式配了
spring.security.user.password。
第 2、3 条是"用户显式要求了,就必须给"的语义:即使有 OAuth2 依赖,
只要你显式写了 spring.security.user.password=xxx,内存用户照样创建。
因为用户可能就是想同时有两条认证路径。
ConfigurationPhase.PARSE_CONFIGURATION 是关键:这个条件需要在解析配置阶段就判定,
因为它内部有 @ConditionalOnProperty(不需要 bean 信息),而且它要阻止整个配置类被进一步处理。
如果写成 REGISTER_BEAN,配置类会被完整解析、bean 方法会被注册,
再由条件在最后一步否决——浪费且可能触发副作用。
第 15 章讲过这条规则:条件里只用 @ConditionalOnProperty / @ConditionalOnClass /
@ConditionalOnMissingClass 的,应该用 PARSE_CONFIGURATION;涉及 bean 的,必须用 REGISTER_BEAN。
这里三个分支全是非 bean 条件,所以选 PARSE_CONFIGURATION。
3.3 {noop} 前缀与 PASSWORD_ALGORITHM_PATTERN
private String getOrDeducePassword(SecurityProperties.User user, @Nullable PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.warn(...);
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}
这段逻辑解决一个实际问题:Spring Security 的 DaoAuthenticationProvider 需要知道密码用什么算法加密的。
约定是:密码字符串以 {算法名} 开头({bcrypt}、{noop}、{sha256}…),
DelegatingPasswordEncoder 按前缀选择具体实现。如果用户写的是明文密码
(比如 spring.security.user.password=secret),没有前缀,DelegatingPasswordEncoder 会报错。
所以这里做了两件事:
- 如果有
PasswordEncoderbean(encoder != null)——说明用户配置了编码器, 密码应该已经(或将会)被正确处理,原样返回。 - 否则,如果密码已经有
{...}前缀——用户自己写了算法(比如{bcrypt}$2a$10$...),原样返回。 - 都不满足——加
{noop}前缀,告诉 Spring Security"这是明文,直接比对"。
PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$") 的正则要点:
^\\{ 匹配开头的 {,.+ 贪婪匹配算法名,} 收尾,.*$ 是剩余部分。
注意 .+ 是贪婪的,所以 {a}b}c 会匹配到最后一个 }——这是可接受的,因为算法名里不会有 }。
{noop} 是一个明确的不安全标记。Spring Security 会在日志里警告使用了 noop 编码器吗?
本仓库没有做这件事——警告只针对"随机生成的密码"。
也就是说:如果你显式配了明文密码,不会有任何警告。这是需要用户自己知道的一点。
3.4 为什么 inMemoryUserDetailsManager 没有 @ConditionalOnMissingBean
这个 bean 方法上没有 @ConditionalOnMissingBean(InMemoryUserDetailsManager.class)——
因为类级别已经有了 @ConditionalOnMissingBean(UserDetailsService.class),
而 InMemoryUserDetailsManager 实现了 UserDetailsService。
如果用户自己注册了任何 UserDetailsService(含 InMemoryUserDetailsManager),类级别条件就不匹配。
在方法上再加一次是冗余的。这个"条件尽量上提"的写法让配置类更简洁,
但代价是:用户如果想"用 Spring Boot 的默认用户,但换一个 UserDetailsService 实现"就做不到——
因为类条件会直接让整个配置退让。
3.5 roles 空数组的语义
StringUtils.toStringArray(roles) 当 roles 为空 List 时返回空数组。
User.withUsername(...).roles() 接收空数组意味着"这个用户没有任何角色"。
配合默认 SecurityFilterChain 的 anyRequest().authenticated()——
只要认证通过就能访问所有接口,有没有角色无所谓。
这与传统认知(默认用户应该有 USER 角色)不同:Spring Boot 的默认用户没有角色。
如果你写 requests.requestMatchers("/admin/**").hasRole("ADMIN"),
默认用户是访问不了的——因为没有 ROLE_ADMIN。这是很多人第一次配安全时踩的坑。
4. ServletWebSecurityAutoConfiguration:默认规则的三个嵌套配置
@AutoConfiguration(after = UserDetailsServiceAutoConfiguration.class,
afterName = { "org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration",
"org.springframework.boot.webmvc.test.autoconfigure.MockMvcAutoConfiguration" })
@ConditionalOnClass(EnableWebSecurity.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
public final class ServletWebSecurityAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(DispatcherServletPath.class)
@ConditionalOnClass(DispatcherServletPath.class)
static class PathPatternRequestMatcherBuilderConfiguration {
@Bean
@ConditionalOnMissingBean
PathPatternRequestMatcher.Builder pathPatternRequestMatcherBuilder(
DispatcherServletPath dispatcherServletPath) {
PathPatternRequestMatcher.Builder builder = PathPatternRequestMatcher.withDefaults();
String path = dispatcherServletPath.getPath();
return (!path.equals("/")) ? builder.basePath(path) : builder;
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) {
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN)
@ConditionalOnClass(EnableWebSecurity.class)
@EnableWebSecurity
static class EnableWebSecurityConfiguration {
}
}
4.1 三个嵌套配置,三种退让粒度
| 嵌套类 | 退让条件 | 退让后谁受害 |
|---|---|---|
PathPatternRequestMatcherBuilderConfiguration | 没有 DispatcherServletPath bean | 没有匹配器 builder,路径匹配用默认(无 basePath) |
SecurityFilterChainConfiguration | @ConditionalOnDefaultWebSecurity | 没有任何默认规则 |
EnableWebSecurityConfiguration | 已有 springSecurityFilterChain bean | 不加 @EnableWebSecurity |
注意三个嵌套类的退让条件完全不同——这正是拆成三个的理由。 最重要的是后两个的差别:
SecurityFilterChainConfiguration退让条件是"已有任何SecurityFilterChainbean" (DefaultWebSecurityCondition里的@ConditionalOnMissingBean({ SecurityFilterChain.class }))。EnableWebSecurityConfiguration退让条件是"已有名为springSecurityFilterChain的 bean"。
这两个不是一回事。 SecurityFilterChain 是 Spring Security 6/7 的新抽象(可以有多个),
springSecurityFilterChain 是 FilterChainProxy 这个 bean 的名字(只有一个,由 @EnableWebSecurity
导入的 WebSecurityConfiguration 生成)。
所以存在这样一种状态:用户注册了自己的 SecurityFilterChain bean(比如只保护 /admin/**),
那么 SecurityFilterChainConfiguration 退让(不生成默认规则),
但 EnableWebSecurityConfiguration 不退让(因为 springSecurityFilterChain 还没生成,
它正是由 @EnableWebSecurity 生成的)——所以 @EnableWebSecurity 仍然被加上,
用户的 SecurityFilterChain 才能生效。
这是一个必须同时具备"整体退让"与"骨架保留"的场景:规则可以全部让用户接管,
但"让 Spring Security 生效"这个骨架必须由框架兜底。第 15 章讲过类似的模式
(@ConditionalOnMissingBean 用于"用户可替换",@ConditionalOnBean 用于"依赖前置"),
这里是两者在同一个类里协同。
4.2 @ConditionalOnDefaultWebSecurity:一个自定义条件的完整范例
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(DefaultWebSecurityCondition.class)
public @interface ConditionalOnDefaultWebSecurity {
}
class DefaultWebSecurityCondition extends AllNestedConditions {
DefaultWebSecurityCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnClass({ SecurityFilterChain.class, HttpSecurity.class })
static class Classes {
}
@ConditionalOnMissingBean({ SecurityFilterChain.class })
static class Beans {
}
}
这是本仓库里"自定义 @ConditionalOnXxx 注解"的最小完整范例,值得作为模板记住:
- 注解本身只做一件事:
@Conditional(实现类)。 - 实现类继承
AllNestedConditions(全部满足)或AnyNestedCondition(任一满足)。 - 内部用静态嵌套类分组,每个嵌套类上挂标准条件注解。
- 构造器里指定
ConfigurationPhase。
AllNestedConditions 有个反直觉的地方(第 15 章讲过):它的 getMatchOutcome 在
PARSE_CONFIGURATION 阶段永远返回"匹配",因为那时 bean 定义还没注册完,
无法判断 @ConditionalOnMissingBean。所以必须用 REGISTER_BEAN 阶段——
这也是为什么这个条件类里明明有 @ConditionalOnClass(本来可以在 PARSE 阶段判定),
却仍然选了 REGISTER_BEAN。阶段的选定以"最严格的条件"为准。
ConfigurationPhase.REGISTER_BEAN 的另一个后果:这个条件不能用于阻止配置类的解析,
只能阻止 bean 的注册。对这里没影响(配置类里只有 bean 方法),但在设计自己的条件时要意识到。
为什么这个注解要存在? 因为它被两处复用:
ServletWebSecurityAutoConfiguration 和 ManagementWebSecurityAutoConfiguration。
两处需要完全一致的"用户没有自定义安全"判定。如果各自写一遍
@ConditionalOnClass({...}) @ConditionalOnMissingBean({...}),
将来修改时容易漏改一处。抽成注解是第 15 章讲的"条件复用"纪律。
4.3 默认规则:anyRequest().authenticated() + formLogin + httpBasic
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
三行的含义:
- 所有请求都要认证。
- 开启表单登录(浏览器访问会跳转登录页)。
- 开启 HTTP Basic(
Authorization: Basic ...)。
同时开启两种认证方式,是因为 Spring Security 的内容协商机制:
它会根据请求的 Accept 头决定返回登录页(HTML)还是 401(其它)。
这是 SecurityFilterChainConfiguration 类注释里说的:
"It relies on Spring Security's content-negotiation strategy to determine what sort of authentication to use."
注意这里没有 http.csrf(...)——CSRF 由 Spring Security 默认开启,
HttpSecurity 的默认构造就包含它。自动配置只写"Spring Boot 想额外加的部分"。
4.4 @Order(SecurityFilterProperties.BASIC_AUTH_ORDER) 的取值
public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
Ordered.LOWEST_PRECEDENCE 是 Integer.MAX_VALUE,减 5 后是 Integer.MAX_VALUE - 5——
优先级极低(最后被考虑)。这样设计的意图:
如果用户注册了自己的 SecurityFilterChain,通常也会给它一个 @Order(比如 1)。
数值越小优先级越高,所以用户的 chain 会排在默认 chain 前面,先匹配、先生效。
默认 chain 排在最后,作为"兜底"。
-5 而不是 -1 或 -100:留出了一点空间,但具体为什么是 5 未见于注释。
推测是给框架内部其它可能需要"比默认更靠后"的配置留位置。
ManagementWebSecurityAutoConfiguration 用的也是同一个常量——
所以管理端点那条 chain 与默认 chain order 相同,靠 @AutoConfiguration(before = ...)
决定的注册顺序来区分先后。
待核实:两条 order 相同的
SecurityFilterChain同时存在时的实际选择行为 (是否取决于 bean 定义注册顺序)未逐行确认。可自测:同时启用 Actuator 与 Security, 打断点观察FilterChainProxy里 chain 的顺序。
4.5 PathPatternRequestMatcher.Builder:为什么需要 basePath
PathPatternRequestMatcher.Builder builder = PathPatternRequestMatcher.withDefaults();
String path = dispatcherServletPath.getPath();
return (!path.equals("/")) ? builder.basePath(path) : builder;
DispatcherServletPath 是 Spring Boot 对 spring.mvc.servlet.path 的抽象(第 19 章)。
如果 DispatcherServlet 映射在 /app/* 而不是 /,那么安全配置里写的路径
(比如 /admin/**)实际需要匹配 /app/admin/**。
basePath(path) 就是把这个前缀告诉匹配器。
(!path.equals("/")) ? ... : builder——等于 / 时不设 basePath,
因为设成 / 会导致路径变成 //admin/**(双斜杠)。这是一个很小的防御,
但没有它,所有默认配置的应用都会踩坑。
注意这个 bean 的条件是 @ConditionalOnBean(DispatcherServletPath.class)
且 @ConditionalOnClass(DispatcherServletPath.class)——两个都写了。
ConditionalOnBean 已经隐含了类存在(bean 存在则类必存在),
第二个 @ConditionalOnClass 是冗余的。
推测是防御性写法或历史遗留。
推测:
@ConditionalOnClass(DispatcherServletPath.class)与@ConditionalOnBean(DispatcherServletPath.class)重复,前者冗余。本轮未找到必须性证据。
5. SecurityFilterAutoConfiguration:把过滤器注册进 Servlet 容器
@AutoConfiguration(after = ServletWebSecurityAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(SecurityFilterProperties.class)
@ConditionalOnClass({ AbstractSecurityWebApplicationInitializer.class, SessionCreationPolicy.class })
public final class SecurityFilterAutoConfiguration {
private static final String DEFAULT_FILTER_NAME = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME;
@Bean
@ConditionalOnBean(name = DEFAULT_FILTER_NAME)
DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(
SecurityFilterProperties securityFilterProperties) {
DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(
DEFAULT_FILTER_NAME);
registration.setOrder(securityFilterProperties.getOrder());
registration.setDispatcherTypes(getDispatcherTypes(securityFilterProperties));
return registration;
}
private @Nullable EnumSet<DispatcherType> getDispatcherTypes(SecurityFilterProperties securityFilterProperties) {
if (securityFilterProperties.getDispatcherTypes() == null) {
return null;
}
return securityFilterProperties.getDispatcherTypes()
.stream()
.map((type) -> DispatcherType.valueOf(type.name()))
.collect(Collectors.toCollection(() -> EnumSet.noneOf(DispatcherType.class)));
}
}
5.1 DelegatingFilterProxyRegistrationBean 的"懒查找"语义
这个类(第 18 章讲过)注册的是一个 DelegatingFilterProxy:
它是一个"代理过滤器",本身不做任何事,每次请求时按名字去 Spring 容器里找真正的 Filter bean。
这里传的名字是 springSecurityFilterChain,也就是 FilterChainProxy。
为什么必须走代理而不是直接注册?因为Servlet 容器创建过滤器的时机早于 Spring 上下文刷新。
如果直接 new FilterChainProxy(),它内部的 SecurityFilterChain 列表还是空的。
走代理的话,第一次请求时才去容器里取,那时上下文已完全就绪。
这是第 18 章"Servlet 组件注册"的一个核心模式:凡是依赖 Spring bean 的 Servlet 组件,
都必须通过 DelegatingXxxRegistrationBean 注册。
5.2 两个哨兵类的选择:AbstractSecurityWebApplicationInitializer + SessionCreationPolicy
为什么是两个?推测:
AbstractSecurityWebApplicationInitializer提供DEFAULT_FILTER_NAME常量—— 本类直接引用了它的静态字段,必须存在。SessionCreationPolicy是spring-security-config里的类,代表"完整的 Spring Security 配置支持"。
第二个哨兵相对随意(本类并不使用 SessionCreationPolicy)。这属于"选一个代表性的类"
而非"选实际使用的类"——与 §2.1 的纪律略有出入。
推测:
SessionCreationPolicy作为哨兵是历史选择,本类并未使用它。 更精确的哨兵应是DelegatingFilterProxyRegistrationBean(Spring Boot 自己的类,恒存在,无意义) 或AbstractSecurityWebApplicationInitializer一个就已足够。
5.3 SecurityFilterProperties 的两个 order 常量
public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
private int order = DEFAULT_FILTER_ORDER;
private Set<DispatcherType> dispatcherTypes = EnumSet.allOf(DispatcherType.class);
OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER 是 Spring Boot 的常量(第 18 章),
表示"请求包装类过滤器"的最大 order。减 100 意味着安全过滤器排在
所有请求包装过滤器之后(order 值越大越靠后?这里需要看 OrderedFilter 的语义——
在 Spring Boot 的 OrderedFilter 里,order 越小优先级越高,
REQUEST_WRAPPER_FILTER_MAX_ORDER 是一个上界值)。
实际值:spring.security.filter.order 默认是 -100。
这个数值被写进了 additional-spring-configuration-metadata.json:
{
"name": "spring.security.filter.order",
"defaultValue": -100
}
为什么要单独维护一个 JSON 来声明默认值? 因为 -100 是表达式
OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100 算出来的,
配置元数据注解处理器(第 16 章)无法从字段初始化表达式里推出常量值,
只能推出"有默认值但未知"。手工补进 JSON 是为了让 IDE 能显示 -100。
这是第 16 章讲过的一个实用技巧:当默认值是编译期常量表达式时,
用 additional-spring-configuration-metadata.json 补声明。
同理 spring.security.filter.dispatcher-types 的默认值
(EnumSet.allOf(DispatcherType.class))也被手工补成
["async","error","forward","include","request"]——注意是字母序,
因为 EnumSet 的迭代顺序是枚举声明顺序,而 JSON 里写的是排序后的,
为了让 IDE 显示稳定。
5.4 DispatcherType 转换:type.name() 而不是 type.toString()
.map((type) -> DispatcherType.valueOf(type.name()))
SecurityFilterProperties.DispatcherType 是 Spring Boot 自己定义的枚举
(org.springframework.boot.web.servlet.DispatcherType),
与 Servlet API 的 jakarta.servlet.DispatcherType 同名但不同包。
转换用 name() 而非 toString()——因为 toString() 可能被覆写,
name() 恒等于枚举常量名。
为什么 Spring Boot 要自己定义一个 DispatcherType? 为了让配置属性不直接依赖
Servlet API——这样在非 Servlet 环境下(比如解析配置文件时)也能加载属性类。
这是一个"隔离 API 依赖"的常见手法,第 22 章讲数据源配置时也见过类似处理。
5.5 null 与空集合的不同语义
if (securityFilterProperties.getDispatcherTypes() == null) {
return null;
}
返回 null 传给 registration.setDispatcherTypes(null) 意味着"不限制 dispatcher types"
(Servlet 容器默认行为),而返回空 EnumSet 意味着"不匹配任何 dispatcher type"
(过滤器永远不执行)。这两个语义完全相反,所以必须区分。
getDispatcherTypes() 声明为 @Nullable,但字段初始化是 EnumSet.allOf(...),
正常绑定下不会是 null——只有用户显式写 spring.security.filter.dispatcher-types=
(空值)时才可能为 null。这个 null 分支是防御性的。
6. Actuator 安全:ManagementWebSecurityAutoConfiguration
@AutoConfiguration(before = ServletWebSecurityAutoConfiguration.class,
afterName = { "org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointAutoConfiguration",
"org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration" })
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ RequestMatcher.class, WebEndpointAutoConfiguration.class })
@ConditionalOnDefaultWebSecurity
public final class ManagementWebSecurityAutoConfiguration {
@Bean
@Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
SecurityFilterChain managementSecurityFilterChain(Environment environment, HttpSecurity http) {
http.authorizeHttpRequests((requests) -> {
if (ClassUtils.isPresent("org.springframework.boot.health.actuate.endpoint.HealthEndpoint",
getClass().getClassLoader())) {
requests.requestMatchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
}
requests.anyRequest().authenticated();
});
if (ClassUtils.isPresent("org.springframework.web.servlet.DispatcherServlet", null)) {
http.cors(withDefaults());
}
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}
private RequestMatcher healthMatcher() {
return EndpointRequest.to(HealthEndpoint.class);
}
private RequestMatcher additionalHealthPathsMatcher() {
return EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class);
}
}
6.1 类级别字符串守卫:ClassUtils.isPresent 的两个变体
方法体里有两处 ClassUtils.isPresent,参数不同:
ClassUtils.isPresent("org.springframework.boot.health.actuate.endpoint.HealthEndpoint",
getClass().getClassLoader()); // 传了 classLoader
ClassUtils.isPresent("org.springframework.web.servlet.DispatcherServlet", null); // 传 null
传 null 表示用 ClassUtils 的默认类加载器(ClassUtils.getDefaultClassLoader(),
即线程上下文类加载器)。传 getClass().getClassLoader() 表示用定义这个类的加载器。
两者在大多数情况下相同,但在复杂类加载环境(OSGi、DevTools 的 RestartClassLoader、
第 27 章的 LaunchedClassLoader)下会有差别。
为什么 HealthEndpoint 用显式 classLoader? 推测是历史演进:
health 端点在 Spring Boot 4 里被拆到了 spring-boot-health 模块,
包路径也变了(从 org.springframework.boot.actuate.health 到
org.springframework.boot.health.actuate.endpoint)。显式传加载器可能是为了
确保与当前类用同一个加载器,避免"两个加载器里各有一份 HealthEndpoint"的诡异情况。
这与第 28 章第七十七定律一致:可选依赖一律用字符串类名守卫。
注意 Javadoc 里 HealthEndpoint 的完整包路径
org.springframework.boot.health.actuate.endpoint.HealthEndpoint——
这个包路径结构很特别(health 在 actuate 前面),是 Spring Boot 4 模块重构的结果。
6.2 为什么只放行 health,不放行 info
afterName 里提到了 InfoEndpointAutoConfiguration,但方法体里只放行了 health。
推测原因:info 端点的内容可能包含敏感信息(构建信息、git 信息、自定义 info 条目), 不适合默认放行;而 health 端点是给负载均衡/容器探针用的,必须免认证。
这个不对称是刻意的安全决策,但源码里没有注释说明,属于"需要记住的约定"。
6.3 EndpointRequest.toAdditionalPaths(WebServerNamespace.SERVER, ...):为什么需要它
第 25 章讲过:管理端口(management.port)可以与主端口不同,
也可以相同但位于不同的 WebServerNamespace。
Spring Boot 支持给同一个端点配置"额外路径"(management.endpoints.web.exposure
配合自定义路径),这些额外路径可能挂在主服务器命名空间下。
所以"放行 health"需要匹配两类路径:
- 管理命名空间下的
/actuator/health(healthMatcher())。 - 服务器命名空间下的额外 health 路径(
additionalHealthPathsMatcher())。
两个 matcher 用 requestMatchers(a, b) 组合成"或"关系。
这是一个很容易漏的配置:只写 EndpointRequest.to(HealthEndpoint.class) 的话,
如果用户配了额外路径,那些路径仍然需要认证,探针会失败。
6.4 http.cors(withDefaults()) 的条件是 DispatcherServlet 存在
if (ClassUtils.isPresent("org.springframework.web.servlet.DispatcherServlet", null)) {
http.cors(withDefaults());
}
为什么启用 CORS 要判断 DispatcherServlet?因为 http.cors() 默认会去找
CorsConfigurationSource bean,而这个 bean 通常由 Spring MVC 的
WebMvcAutoConfiguration 提供(通过 CorsFilter 或 @CrossOrigin)。
在非 MVC 环境(比如只有 Servlet 但没有 Spring MVC)下启用 CORS 会失败或无意义。
这是"功能依赖前置条件"的典型写法:不是判断 CORS 类是否存在
(CorsConfigurationSource 属于 Spring Web,肯定存在),
而是判断"能提供 CORS 配置的上游是否存在"。
6.5 @ConditionalOnDefaultWebSecurity 在这里的复用
ManagementWebSecurityAutoConfiguration 也用了 @ConditionalOnDefaultWebSecurity——
与 §4.2 完全相同。效果是:用户一旦提供自己的 SecurityFilterChain,
管理端点的 security 自动配置也一起退让,用户必须自己处理 /actuator/health 的放行。
这是合理的(框架不能替用户决定安全策略),但也是很多人困惑的来源: "我加了自己的 SecurityFilterChain,为什么 health 端点突然要认证了?"
7. EndpointRequest:本模块最复杂的一个类
EndpointRequest 是一个工厂类,产出各种 RequestMatcher。它复杂在三层嵌套 + 两个抽象基类 +
一个延迟初始化协议。把它读通,等于把第 25 章(Actuator 子上下文)与第 15 章(条件)串起来。
7.1 工厂方法的五种形态
| 方法 | 返回类型 | 语义 |
|---|---|---|
toAnyEndpoint() | EndpointRequestMatcher | 所有端点 + links |
to(Class<?>...) / to(String...) | EndpointRequestMatcher | 指定端点 |
toLinks() | LinksRequestMatcher | 只有 links 端点 |
toAdditionalPaths(WebServerNamespace, Class<?>.../String...) | AdditionalPathsEndpointRequestMatcher | 指定命名空间下的额外路径 |
每个返回的 matcher 都支持 excluding(...) / excludingLinks() 做减法,
返回新的 matcher 实例(不可变设计):
public EndpointRequestMatcher excluding(Class<?>... endpoints) {
List<Object> excludes = new ArrayList<>(this.excludes);
excludes.addAll(Arrays.asList((Object[]) endpoints));
return new EndpointRequestMatcher(this.includes, excludes, this.includeLinks, this.httpMethod);
}
excludes 是 List<Object> 而不是 List<Class<?>>——因为要同时容纳
Class<?> 和 String 两种"端点标识"。这是用 Object 换取两种输入的统一处理,
代价是失去了类型安全,所以 getEndpointId 里必须做穷尽判断:
protected EndpointId getEndpointId(Object source) {
if (source instanceof EndpointId endpointId) {
return endpointId;
}
if (source instanceof String string) {
return EndpointId.of(string);
}
if (source instanceof Class<?> sourceClass) {
return getEndpointId(sourceClass);
}
throw new IllegalStateException("Unsupported source " + source);
}
注意最后是 throw 而不是返回 null——输入类型错误是编程错误,必须快速失败。
第 15 章讲过这个纪律:框架内部的非法状态用 IllegalStateException,
配置错误用 Assert,可恢复的缺失返回 @Nullable。
7.2 ApplicationContextRequestMatcher:延迟初始化的协议
这是整个安全模块最有设计感的一个类:
public abstract class ApplicationContextRequestMatcher<C> implements RequestMatcher {
private final Class<? extends C> contextClass;
private volatile boolean initialized;
private final Object initializeLock = new Object();
@Override
public final boolean matches(HttpServletRequest request) {
WebApplicationContext webApplicationContext = WebApplicationContextUtils
.getRequiredWebApplicationContext(request.getServletContext());
if (ignoreApplicationContext(webApplicationContext)) {
return false;
}
Supplier<C> context = () -> getContext(webApplicationContext);
if (!this.initialized) {
synchronized (this.initializeLock) {
if (!this.initialized) {
initialized(context);
this.initialized = true;
}
}
}
return matches(request, context);
}
}
它解决什么问题?RequestMatcher 是在配置阶段创建的,但它需要的信息在那时还不完整。
具体来说:EndpointRequest.to(HealthEndpoint.class) 在创建 matcher 时,
需要知道 health 端点的实际路径(/actuator/health)。但端点路径依赖于:
Actuator 的 WebEndpointProperties(base-path)、management.port 配置、
端点是否暴露……这些在 SecurityFilterChain bean 定义注册时可能还没确定。
解决方案是**"首次请求时初始化"**:
matches()是final—— 子类不能改这个协议。- 先从
ServletContext拿到WebApplicationContext。 ignoreApplicationContext(ctx)给子类一个"这个上下文我不关心"的否决机会。- 双重检查锁定(
volatile boolean+synchronized)保证initialized(context)只跑一次。 - 之后每次都走
matches(request, context),context是一个Supplier(惰性求值)。
为什么 context 是 Supplier 而不是直接传对象? 因为 getContext 可能抛异常
(比如 getBean 找不到)。用 Supplier 把"取上下文"推迟到真正需要时,
ignoreApplicationContext 返回 true 的情况下根本不会取。
这是第 27 章讲过的"惰性 + 缓存"模式的一次复现:昂贵动作做一次,结果留住, 且失败路径不触发昂贵动作。
7.3 泛型参数 C 的两种用法
private final Class<? extends C> contextClass;
@SuppressWarnings("unchecked")
private C getContext(WebApplicationContext webApplicationContext) {
if (this.contextClass.isInstance(webApplicationContext)) {
return (C) webApplicationContext;
}
return webApplicationContext.getBean(this.contextClass);
}
C 可以是两种东西:
- 上下文类型本身(
WebApplicationContext.class)——isInstance为真,直接返回上下文。 - 上下文里的某个 bean 类型(
DispatcherServletPath.class)——isInstance为假,走getBean。
StaticResourceRequest.StaticResourceRequestMatcher 用的是后者:
super(DispatcherServletPath.class),它不需要整个上下文,只需要 DispatcherServletPath bean
来算相对路径。
这是一个很优雅的设计:同一个基类,通过改变 C 就能在"我要整个上下文"与
"我只要一个 bean"之间切换,不需要两套代码。第 25 章讲 Actuator 端点时也见过类似的
"泛型化依赖注入"手法。
7.4 ignoreApplicationContext:管理上下文的隔离
基类的默认实现是 return false(不忽略)。两个子类都覆写了它:
StaticResourceRequestMatcher:
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext) {
return hasServerNamespace(applicationContext, "management");
}
EndpointRequest.AbstractRequestMatcher:
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext) {
ManagementPortType managementPortType = this.managementPortType;
if (managementPortType == null) {
managementPortType = ManagementPortType.get(applicationContext.getEnvironment());
this.managementPortType = managementPortType;
}
return ignoreApplicationContext(applicationContext, managementPortType);
}
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext,
ManagementPortType managementPortType) {
return managementPortType == ManagementPortType.DIFFERENT
&& !hasWebServerNamespace(applicationContext, WebServerNamespace.MANAGEMENT);
}
两者的逻辑方向相反但目的一致:
StaticResourceRequestMatcher:如果上下文是管理命名空间,忽略。 因为管理端口上不应该有/css/**这些静态资源,匹配它们毫无意义。EndpointRequest:如果管理端口与主端口不同(ManagementPortType.DIFFERENT), 且当前上下文没有管理命名空间,则忽略。 意思是:管理端点在另一个端口上,当前这个(主端口)上下文里不该匹配端点路径。
managementPortType 字段被缓存(if (managementPortType == null)),
因为 ManagementPortType.get(environment) 要解析属性——没必要每次请求都算。
注意它不是 volatile,而外层 delegate 是 volatile。
这是一个潜在的可见性问题:多线程下可能重复计算(无害,幂等),
但看不到最新值(有害吗?ManagementPortType 在应用生命周期内不变,所以无害)。
推测:
managementPortType未加volatile是可接受的,因为其值在应用生命周期内恒定, 重复计算是幂等的。但严格来说存在"线程 A 写入未完成、线程 B 读到部分构造对象"的风险 (ManagementPortType是枚举,不存在此风险)。本轮未找到针对此的注释。
7.5 hasServerNamespace 与 hasImplicitServerNamespace
protected final boolean hasWebServerNamespace(ApplicationContext applicationContext,
WebServerNamespace webServerNamespace) {
return hasServerNamespace(applicationContext, webServerNamespace.getValue())
|| hasImplicitServerNamespace(applicationContext, webServerNamespace);
}
private boolean hasImplicitServerNamespace(ApplicationContext applicationContext,
WebServerNamespace webServerNamespace) {
return WebServerNamespace.SERVER.equals(webServerNamespace)
&& getServerNamespace(applicationContext) == null && applicationContext.getParent() == null;
}
"隐式服务器命名空间"的概念:当上下文没有显式命名空间且没有父上下文时,它就是主服务器上下文。
为什么要这个?因为绝大多数应用不会配置 WebServerNamespace,
WebServerApplicationContext 的 getServerNamespace() 返回 null。
如果只判断"命名空间等于 SERVER",这些普通应用就匹配不上。
这就是"默认值"的处理:显式配置时按配置判断,没配置时按"是不是根上下文"推断。
第 17 章讲 WebApplicationType 推断时见过同样的思路——
"用户没说的时候,从环境里推一个最合理的默认"。
7.6 hasServerNamespace 里的字符串常量与 ClassUtils.isPresent
private static final String WEB_SERVER_CONTEXT_CLASS = "org.springframework.boot.web.server.context.WebServerApplicationContext";
protected final boolean hasServerNamespace(@Nullable ApplicationContext context, String serverNamespace) {
if (!ClassUtils.isPresent(WEB_SERVER_CONTEXT_CLASS, null)) {
return false;
}
return WebServerApplicationContext.hasServerNamespace(context, serverNamespace);
}
又是一次"字符串守卫可选依赖"(第七十七定律)。
WebServerApplicationContext 属于 spring-boot-web-server 模块,
在纯 Servlet 但非 Spring Boot Web 环境下可能不存在。
注意 ClassUtils.isPresent 的结果没有被缓存——每次调用都做一次类加载检查。
ClassUtils.isPresent 内部有缓存吗?Spring 的 ClassUtils.isPresent 每次都会
尝试 Class.forName,失败后抛异常再捕获——成本不低。
在 matches() 这种每请求都走的路径上,这是一个性能隐患。
不过实际上 ignoreApplicationContext 里对 managementPortType 做了缓存,
而 hasWebServerNamespace 只在 ManagementPortType.DIFFERENT 时才走到——
所以正常应用(同端口)不会每次都调用它。
待核实:
ClassUtils.isPresent内部是否有缓存(Spring Framework 实现细节, 不在本仓库)未核实。可自测:在hasServerNamespace上打断点统计调用次数, 或用 JMH 微基准测量。
7.7 RequestMatcherProvider:为什么需要这一层抽象
@FunctionalInterface
public interface RequestMatcherProvider {
RequestMatcher getRequestMatcher(String pattern, @Nullable HttpMethod httpMethod);
}
class PathPatternRequestMatcherProvider implements RequestMatcherProvider {
private final Function<String, String> pathFactory;
@Override
public RequestMatcher getRequestMatcher(String pattern, @Nullable HttpMethod httpMethod) {
return PathPatternRequestMatcher.withDefaults().matcher(httpMethod, this.pathFactory.apply(pattern));
}
}
pathFactory 是一个 Function<String, String>,负责把"端点路径"转换成"实际匹配路径"。
注入的两个实现:
// MVC
new PathPatternRequestMatcherProvider(servletPath::getRelativePath);
// Jersey
new PathPatternRequestMatcherProvider(applicationPath::getRelativePath);
为什么要抽象出 RequestMatcherProvider?因为Actuator 可能跑在 MVC 上,也可能跑在 Jersey 上,
两者的"基准路径"计算方式不同。管理子上下文里(SecurityRequestMatchersManagementContextConfiguration)
按 classpath 二选一:
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnBean(DispatcherServletPath.class)
public static class MvcRequestMatcherConfiguration {
@Bean
@ConditionalOnMissingBean
public RequestMatcherProvider requestMatcherProvider(DispatcherServletPath servletPath) {
return new PathPatternRequestMatcherProvider(servletPath::getRelativePath);
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ResourceConfig.class)
@ConditionalOnMissingClass("org.springframework.web.servlet.DispatcherServlet")
@ConditionalOnBean(JerseyApplicationPath.class)
public static class JerseyRequestMatcherConfiguration {
@Bean
public RequestMatcherProvider requestMatcherProvider(JerseyApplicationPath applicationPath) {
return new PathPatternRequestMatcherProvider(applicationPath::getRelativePath);
}
}
Jersey 分支的条件里有 @ConditionalOnMissingClass("org.springframework.web.servlet.DispatcherServlet")——
两个分支互斥。这与第 26 章讲的两个 bridge(Brave / OTel)用 @ConditionalOnMissingBean
做互斥是同一套思路。
注意 Jersey 分支的 requestMatcherProvider 没有 @ConditionalOnMissingBean,
而 MVC 分支有。因为 MVC 分支先注册(顺序上在前),用户如果自己提供了就跳过;
Jersey 分支只在没有 MVC 时才激活,那时没有别的 provider,不需要再判断。
7.8 找不到 RequestMatcherProvider 时的兜底
protected RequestMatcherProvider getRequestMatcherProvider(WebApplicationContext context) {
try {
return context.getBean(RequestMatcherProvider.class);
}
catch (NoSuchBeanDefinitionException ex) {
return (pattern, method) -> PathPatternRequestMatcher.withDefaults().matcher(method, pattern);
}
}
try/catch (NoSuchBeanDefinitionException) 而不是 getBeanProvider().getIfAvailable()——
两者都能表达"可能没有",但这里用 try/catch 是因为异常路径也是有效路径,
且 @FunctionalInterface 让兜底实现只需一行 lambda。
兜底实现不做路径转换(pathFactory 是恒等函数)——也就是说,
没有 DispatcherServletPath bean 时假设路径就是根路径。对绝大多数应用成立。
7.9 EMPTY_MATCHER 哨兵
private static final RequestMatcher EMPTY_MATCHER = (request) -> false;
private RequestMatcher createDelegate(WebApplicationContext context) {
try {
return createDelegate(context, new RequestMatcherFactory());
}
catch (NoSuchBeanDefinitionException ex) {
return EMPTY_MATCHER;
}
}
如果创建 delegate 时发现缺少必要的 bean(比如 WebEndpointDiscoverer 还没准备好),
返回一个永远不匹配的 matcher,而不是抛异常。
为什么?因为安全配置的失败不应该阻断应用启动。 如果 health matcher 构造失败就抛异常,应用起不来; 如果返回"永不匹配",应用能起来,只是 health 端点被保护了(需要认证)—— 后者显然比前者好。
这是"降级优于失败"的又一次体现,与第 28 章 __EnvironmentPostProcessor
缺失时静默跳过是同一条定律(第八十定律)。
注意 EMPTY_MATCHER 是 static final——共享单例。
它无状态,可以安全共享。第 27 章讲过一个类似手法:
Optimizations 里共享 FILE_NOT_FOUND_EXCEPTION 实例省掉 fillInStackTrace 开销。
7.10 toString 也做了端点格式化
protected String toString(List<Object> endpoints, String emptyValue) {
return (!endpoints.isEmpty()) ? endpoints.stream()
.map(this::getEndpointId)
.map(Object::toString)
.collect(Collectors.joining(", ", "[", "]")) : emptyValue;
}
matcher 的 toString() 会输出它所覆盖的端点列表——这是为了条件报告与调试。
当你在日志里看到 EndpointRequestMatcher[health, info] 时,
就知道这条规则保护了哪些端点。
第 16 章讲过:"调试信息的价值在凌晨三点最高"。给 matcher 写有意义的 toString()
成本极低,收益在排查问题时极大。
8. 静态资源与 PathRequest
8.1 StaticResourceLocation:七种约定位置
CSS("/css/**"),
JAVA_SCRIPT("/js/**"),
IMAGES("/images/**"),
WEB_JARS("/webjars/**"),
FAVICON("/favicon.*", "/icons/icon-*"),
FONTS("/fonts/**");
注意 FAVICON 有两个 pattern——所以 patterns 是 String[] 而不是 String。
/favicon.* 覆盖 favicon.ico、favicon.png 等;
/icons/icon-* 覆盖 PWA 图标。
这个枚举列出的是"Spring Boot 约定的静态资源位置",与第 19 章讲的
spring.web.resources.static-locations 默认位置(/static、/public 等)不是一回事:
后者是"文件放在 classpath 哪里",前者是"URL 路径前缀是什么"。
8.2 StaticResourceRequest 的不可变 API
public StaticResourceRequestMatcher atCommonLocations() {
return at(EnumSet.allOf(StaticResourceLocation.class));
}
public StaticResourceRequestMatcher at(StaticResourceLocation first, StaticResourceLocation... rest) {
return at(EnumSet.of(first, rest));
}
at(first, rest) 的签名强制至少一个参数——不能写 at()。
这是 API 设计上的一个小技巧:用参数列表形状表达"不能为空"的约束,
比运行时 Assert.notEmpty 更早(编译期)发现问题。
EnumSet.of(first, rest):EnumSet 的 of(E first, E... rest) 重载正是为这种签名准备的,
返回的是 RegularEnumSet 或 JumboEnumSet(按常量个数自动选择),比 HashSet 高效得多。
excluding 返回新实例:
public StaticResourceRequestMatcher excluding(Set<StaticResourceLocation> locations) {
Assert.notNull(locations, "'locations' must not be null");
Set<StaticResourceLocation> subset = new LinkedHashSet<>(this.locations);
subset.removeAll(locations);
return new StaticResourceRequestMatcher(subset);
}
new LinkedHashSet<>(this.locations) 拷贝后 removeAll——保留原顺序(LinkedHashSet)
的同时不修改原对象。用 LinkedHashSet 而非 EnumSet 是因为拷贝自 this.locations
(它已经是 LinkedHashSet,见 at(Set) 里的 new LinkedHashSet<>(locations))。
8.3 initialized 里构建 OrRequestMatcher
@Override
protected void initialized(Supplier<DispatcherServletPath> dispatcherServletPath) {
this.delegate = new OrRequestMatcher(getDelegateMatchers(dispatcherServletPath.get()).toList());
}
private Stream<RequestMatcher> getDelegateMatchers(DispatcherServletPath dispatcherServletPath) {
return getPatterns(dispatcherServletPath).map(PathPatternRequestMatcher.withDefaults()::matcher);
}
private Stream<String> getPatterns(DispatcherServletPath dispatcherServletPath) {
return this.locations.stream()
.flatMap(StaticResourceLocation::getPatterns)
.map(dispatcherServletPath::getRelativePath);
}
三层流式处理:
locations.stream()—— 七种位置。.flatMap(StaticResourceLocation::getPatterns)—— 展开成 pattern 流(FAVICON 贡献两个)。.map(dispatcherServletPath::getRelativePath)—— 加上 DispatcherServlet 前缀。
然后每个 pattern 变一个 PathPatternRequestMatcher,全部塞进 OrRequestMatcher(或关系)。
关键:OrRequestMatcher 只构建一次(在 initialized 里,由 §7.2 的双重检查锁定保证),
之后每次请求只是调用它的 matches()。构建 OrRequestMatcher 涉及路径解析,成本不低,
所以这个缓存很必要。
delegate 字段是 volatile——因为 initialized() 在一个线程执行,
matches() 可能在另一个线程执行。第 27 章讲过 volatile 的必要性:
跨线程发布的不可变对象必须用 volatile 或 final。这里因为要延迟赋值,
不能用 final,所以用 volatile。
8.4 PathRequest 与 H2 控制台
public static H2ConsoleRequestMatcher toH2Console() {
return new H2ConsoleRequestMatcher();
}
public static final class H2ConsoleRequestMatcher extends ApplicationContextRequestMatcher<ApplicationContext> {
@Override
protected boolean ignoreApplicationContext(WebApplicationContext applicationContext) {
return hasServerNamespace(applicationContext, "management");
}
// ...
}
toH2Console() 的用途:在安全配置里放行 H2 控制台路径(默认 /h2-console/**)。
H2 控制台是第 22 章讲过的开发期工具,绝不能在生产环境放行,
所以 Spring Boot 把它做成一个显式的、需要主动调用的 API,
而不是默认放行——"危险的便利"必须由用户明确选择。
ignoreApplicationContext 判断管理命名空间——H2 控制台挂在主服务器命名空间下,
所以管理上下文里不该匹配。
PathRequest 与 EndpointRequest 是两个独立的工厂类,
但它们共享同一个基类 ApplicationContextRequestMatcher。
这印证了 §7.2 的设计价值:一个基类,两种上下文获取策略,多个工厂。
9. Reactive 与 RSocket:同一套语义的两种实现
9.1 ReactiveWebSecurityAutoConfiguration:denyAll 兜底
@AutoConfiguration(after = ReactiveUserDetailsServiceAutoConfiguration.class,
afterName = "org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration")
@EnableConfigurationProperties(SecurityProperties.class)
@ConditionalOnClass({ Flux.class, EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebFluxConfigurer.class })
public final class ReactiveWebSecurityAutoConfiguration {
@ConditionalOnWebApplication(type = Type.REACTIVE)
@Configuration(proxyBeanMethods = false)
static class SpringBootWebFluxSecurityConfiguration {
@Bean
@ConditionalOnMissingBean({ ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class,
SecurityWebFilterChain.class })
ReactiveAuthenticationManager denyAllAuthenticationManager() {
return (authentication) -> Mono.error(new UsernameNotFoundException(authentication.getName()));
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(WebFilterChainProxy.class)
@EnableWebFluxSecurity
static class EnableWebFluxSecurityConfiguration {
}
}
}
与 Servlet 版的关键差别:Reactive 版没有默认 SecurityWebFilterChain。
Servlet 版会生成一条 anyRequest().authenticated() 的 chain;
Reactive 版只生成 denyAllAuthenticationManager()——一个永远认证失败的认证管理器。
为什么?因为 @EnableWebFluxSecurity 本身会提供一条默认的 SecurityWebFilterChain
(Spring Security 侧的默认配置)。Spring Boot 不需要(也不能)再生成一条,
否则会有两条 chain 冲突。
所以 Spring Boot 在 Reactive 侧做的只是:
- 加上
@EnableWebFluxSecurity(如果用户没加)。 - 提供一个 denyAll 的认证管理器作为兜底。
denyAllAuthenticationManager 的条件是"没有 ReactiveAuthenticationManager、
没有 ReactiveUserDetailsService、没有 SecurityWebFilterChain"三者都缺。
也就是说:只要用户提供了任何认证相关组件,denyAll 就退让。
这个兜底的意义在于:即使什么都没配,认证也会失败(安全默认), 而不是"认证通过"(危险默认)。这是"默认拒绝"原则的教科书式实现—— 第 15 章讲条件注解时总结过:框架的安全默认值必须是"最严格"的。
9.2 ReactiveUserDetailsServiceAutoConfiguration 的两个差异点
与 Servlet 版对比,有三处不同:
一、条件多了一个 RSocketEnabledOrReactiveWebApplication:
static class RSocketEnabledOrReactiveWebApplication extends AnyNestedCondition {
RSocketEnabledOrReactiveWebApplication() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnBean(RSocketMessageHandler.class)
static class RSocketSecurityEnabledCondition {
}
@ConditionalOnWebApplication(type = Type.REACTIVE)
static class ReactiveWebApplicationCondition {
}
}
Reactive 用户服务不仅在 Reactive Web 应用里生效,在 RSocket 应用里也生效。
因为 RSocket 是响应式的,用的是同一套 ReactiveUserDetailsService。
AnyNestedCondition + REGISTER_BEAN 阶段(因为有 @ConditionalOnBean)。
二、@ConditionalOnMissingBean 的 type 是 ReactiveJwtDecoder 而不是 JwtDecoder:
响应式世界有自己的解码器接口。同样的"字符串守卫可选依赖"规则。
三、日志级别是 info 且消息更短(§2.4 已指出)。
9.3 类注释里的一条重要限制
/**
* Note that the reactive application detection mechanism is limited to web applications
* only. If you're writing a non-web application you will need to configure reactive
* security yourself.
*/
"响应式应用检测机制仅限于 Web 应用"——这是一个写在 Javadoc 里的已知限制。
具体含义:@ConditionalOnWebApplication(type = Type.REACTIVE) 只能识别
Reactive Web 应用(有 WebHandler / RequestMappingHandlerMapping 之类)。
一个纯 RSocket(无 Web)的响应式应用不会被识别为 Reactive Web,
所以 §9.2 的 RSocketEnabledOrReactiveWebApplication 里专门加了
@ConditionalOnBean(RSocketMessageHandler.class) 分支来覆盖这种情况。
这是一个"Javadoc 记录了条件注解的盲区"的例子——
框架知道自己的检测不完备,但没有更好的办法,只能写清楚。
第 17 章讲 WebApplicationType 推断时也遇到过类似的边界。
9.4 RSocketSecurityAutoConfiguration:注解直接加在自动配置类上
@AutoConfiguration
@EnableRSocketSecurity
@ConditionalOnClass({ RSocketServerCustomizer.class, SecuritySocketAcceptorInterceptor.class })
public final class RSocketSecurityAutoConfiguration {
@Bean
RSocketServerCustomizer springSecurityRSocketSecurity(SecuritySocketAcceptorInterceptor interceptor) {
return (server) -> server.interceptors((registry) -> registry.forSocketAcceptor(interceptor));
}
@ConditionalOnClass(AuthenticationPrincipalArgumentResolver.class)
@Configuration(proxyBeanMethods = false)
static class RSocketSecurityMessageHandlerConfiguration {
@Bean
RSocketMessageHandlerCustomizer rSocketAuthenticationPrincipalMessageHandlerCustomizer() {
return (messageHandler) -> messageHandler.getArgumentResolverConfigurer()
.addCustomResolver(new AuthenticationPrincipalArgumentResolver());
}
}
}
注意 @EnableRSocketSecurity 直接加在自动配置类上,而不是像 Servlet/Reactive 那样
放在嵌套配置类里。
为什么?因为 RSocket 场景不存在"用户可能已经加了 @EnableRSocketSecurity"之外的复杂分支——
@ConditionalOnClass({ RSocketServerCustomizer.class, SecuritySocketAcceptorInterceptor.class })
已经保证了类存在,直接加注解最简洁。
对比 Servlet 版的 EnableWebSecurityConfiguration(嵌套 + @ConditionalOnMissingBean(name = ...)),
差别在于:Servlet 场景需要检查用户是否已经注册了 springSecurityFilterChain bean,
而 RSocket 场景只需要检查类是否存在。
这体现了"条件粒度按场景定制"的原则:不要为了统一而统一, 每个场景的退让条件应该按实际需求写。
第二个嵌套配置 RSocketSecurityMessageHandlerConfiguration 注册
AuthenticationPrincipalArgumentResolver——让 @AuthenticationPrincipal 注解
能在 RSocket 消息处理方法上使用。这是第 21 章讲 gRPC 时见过的同类机制:
参数解析器让安全上下文能被注入到处理方法的参数里。
9.5 ReactiveManagementWebSecurityAutoConfiguration 的三点差异
@AutoConfiguration(before = ReactiveWebSecurityAutoConfiguration.class, afterName = { ... })
@ConditionalOnClass({ EnableWebFluxSecurity.class, WebFilterChainProxy.class, WebEndpointAutoConfiguration.class })
@ConditionalOnMissingBean({ SecurityWebFilterChain.class, WebFilterChainProxy.class })
@ConditionalOnWebApplication(type = Type.REACTIVE)
public final class ReactiveManagementWebSecurityAutoConfiguration {
@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http, PreFlightRequestHandler handler) {
http.authorizeExchange((exchanges) -> {
if (ClassUtils.isPresent("org.springframework.boot.health.actuate.endpoint.HealthEndpoint",
getClass().getClassLoader())) {
exchanges.matchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
}
exchanges.anyExchange().authenticated();
});
PreFlightRequestWebFilter filter = new PreFlightRequestWebFilter(handler);
http.addFilterAt(filter, SecurityWebFiltersOrder.CORS);
http.httpBasic(withDefaults());
http.formLogin(withDefaults());
return http.build();
}
@Bean
@ConditionalOnMissingBean({ ReactiveAuthenticationManager.class, ReactiveUserDetailsService.class })
ReactiveAuthenticationManager denyAllAuthenticationManager() { /* ... */ }
}
三点差异:
-
用的是
@ConditionalOnMissingBean({ SecurityWebFilterChain.class, WebFilterChainProxy.class }), 而不是@ConditionalOnDefaultWebSecurity。 这是 Reactive 侧与 Servlet 侧的一个不一致: Servlet 侧复用了自定义注解,Reactive 侧直接写了两个@ConditionalOnMissingBean。 语义上是等价的(都是"用户没自定义安全"),但没有复用。推测:这是历史遗留——
ConditionalOnDefaultWebSecurity检查的是 Servlet 的SecurityFilterChain/HttpSecurity,Reactive 侧类型不同,无法复用同一个注解, 于是就地写了等价条件。若要统一,需要一个泛型化的条件实现。 -
CORS 处理不同:Servlet 侧是
http.cors(withDefaults())(条件性启用), Reactive 侧是无条件http.addFilterAt(new PreFlightRequestWebFilter(handler), SecurityWebFiltersOrder.CORS)。 因为PreFlightRequestHandler是作为方法参数注入的(ServerHttpSecurity http, PreFlightRequestHandler handler), 它由 WebFlux 自动配置提供,存在性在参数注入阶段就保证了。 -
没有
@Order注解:Servlet 侧的默认 chain 有@Order(BASIC_AUTH_ORDER), Reactive 侧没有。推测是ServerHttpSecurity与HttpSecurity的多 chain 机制不同。待核实:Reactive 侧多条
SecurityWebFilterChain的排序机制 (是否靠@Order或注册顺序)未逐行确认。可自测:注册两条 chain 观察匹配顺序。
10. spring-boot-security-test:第 29 章机制的完整验证
第 29 章讲切片测试时提到过:META-INF/spring/<注解全名>.imports 允许第三方 jar 贡献配置,
TypeIncludes 用 .includes 文件声明"哪些类型不算被排除"。
spring-boot-security-test 是这两个机制最典型的样本,且只有两个 Java 类。
10.1 三个资源文件
META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc.imports
META-INF/spring/org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest.includes
META-INF/spring/org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient.imports
第一个文件(@AutoConfigureMockMvc 的 imports):
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
org.springframework.boot.security.test.autoconfigure.webmvc.SecurityMockMvcAutoConfiguration
这就是"加了 spring-boot-security-test 依赖,@WebMvcTest 就自动带 Security"的实现。
不需要改 @WebMvcTest 注解,不需要改切片过滤器——只要在 classpath 上加一个 jar,
它的 imports 文件就会被 ImportsContextCustomizer 收集。
注意这里的排序不是字母序:SecurityAutoConfiguration 在
ServletWebSecurityAutoConfiguration 之前,但字母序里 Se < Se...
实际上 SecurityAutoConfiguration < ServletWebSecurityAutoConfiguration(c < r),
SecurityFilterAutoConfiguration 也在其中。检查:
SecurityAutoConfigurationUserDetailsServiceAutoConfigurationSecurityFilterAutoConfiguration← 这一行破坏了字母序ServletWebSecurityAutoConfiguration
确实是乱序的。为什么这里不强制字母序? 因为 CheckFactoriesFile 只校验
META-INF/spring.factories 和 aot.factories,不校验 *.imports 文件
(第 29 章讲过:imports 文件的格式约束更松,只要求每行一个全限定类名)。
不过顺序对 imports 文件通常不重要——因为自动配置的实际顺序由
@AutoConfiguration(before/after) 决定,而不是文件里的行序。
这一点第 14 章讲过:imports 文件的行序不保证执行顺序。
第二个文件(WebMvcTest.includes):
org.springframework.security.config.annotation.web.WebSecurityConfigurer
org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer
org.springframework.security.web.SecurityFilterChain
.includes 而不是 .imports——这是第 29 章讲的 TypeIncludes 机制:
切片测试默认只扫描"切片关心的组件",.includes 文件用于声明"这些类型也要扫描进来"。
@WebMvcTest 的 WebMvcTypeExcludeFilter 默认只扫描
@Controller、@ControllerAdvice、@JsonComponent、Converter、Filter 等。
SecurityFilterChain(用户自定义的 @Bean SecurityFilterChain)不在这个列表里,
所以会被排除——这会导致"我在测试里定义了 SecurityFilterChain 但没生效"。
.includes 文件补上了这个洞:只要 spring-boot-security-test 在 classpath 上,
这三种类型就被纳入扫描。 这解释了为什么很多教程说"测试安全配置要加 security-test 依赖"。
注意 WebSecurityConfigurer 是 Spring Security 5 的旧接口(6 起废弃),
WebSecurityCustomizer 和 SecurityFilterChain 是新机制。
三个都列着是为了兼容老代码。
第三个文件(@AutoConfigureWebTestClient 的 imports):
org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration
org.springframework.boot.security.test.autoconfigure.webflux.SecurityWebTestClientAutoConfiguration
响应式镜像。注意它没有 SecurityFilterAutoConfiguration(那是 Servlet 专属),
也没有 ManagementWebSecurityAutoConfiguration(切片测试不启用 Actuator)。
10.2 SecurityMockMvcAutoConfiguration:把 SecurityContext 注入 MockMvc
@AutoConfiguration(after = ServletWebSecurityAutoConfiguration.class)
@ConditionalOnClass(MockMvc.class)
public final class SecurityMockMvcAutoConfiguration {
private static final String DEFAULT_SECURITY_FILTER_NAME = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME;
@Bean
@ConditionalOnBean(name = DEFAULT_SECURITY_FILTER_NAME)
SecurityMockMvcBuilderCustomizer securityMockMvcBuilderCustomizer() {
return new SecurityMockMvcBuilderCustomizer();
}
// ...
static class SecurityMockMvcBuilderCustomizer implements MockMvcBuilderCustomizer {
@Override
public void customize(ConfigurableMockMvcBuilder<?> builder) {
builder.apply(new MockMvcConfigurerAdapter() {
@Override
public RequestPostProcessor beforeMockMvcCreated(ConfigurableMockMvcBuilder<?> builder,
WebApplicationContext context) {
return SecurityMockMvcRequestPostProcessors.testSecurityContext();
}
});
}
}
}
它解决一个具体问题:@WithMockUser 设置的 SecurityContext 存在
TestSecurityContextHolder(一个 ThreadLocal)里,
但 MockMvc 的请求跑在"模拟的"线程模型里,需要显式把 TestSecurityContextHolder
的内容搬到 SecurityContextHolder。
SecurityMockMvcRequestPostProcessors.testSecurityContext() 就是这个搬运工。
它被注册成 RequestPostProcessor(每个请求都执行),
所以每次 MockMvc 请求都会带上 @WithMockUser 指定的用户。
@ConditionalOnBean(name = DEFAULT_SECURITY_FILTER_NAME)——
只有真正有安全过滤器链时才注册。如果没有 Spring Security 的过滤器,
搬 SecurityContext 没有意义。
这是第 29 章讲的"MockMvcBuilderCustomizer 扩展点"的一个具体应用:
spring-boot-webmvc-test 定义扩展点,其它模块通过 imports 文件贡献实现。
10.3 HtmlUnit 集成:为什么要换 Executor
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(HtmlUnitDriver.class)
static class SecurityMockMvcHtmlUnitDriverConfiguration {
@Bean
MockMvcHtmlUnitDriverCustomizer securityDelegateMockMvcHtmlUnitDriverCustomizer() {
return (driver) -> driver
.setExecutor(new DelegatingSecurityContextExecutor(Executors.newSingleThreadExecutor()));
}
}
HtmlUnit 的 WebDriver 有一个内部 Executor 线程池来处理异步操作。
这些线程不在 Spring Security 的上下文里,所以 SecurityContextHolder
(ThreadLocal)在这些线程里是空的。
DelegatingSecurityContextExecutor 包装 Executor,在提交任务时把当前
SecurityContext 传过去——这是 Spring Security 提供的标准跨线程传播工具。
这里用 Executors.newSingleThreadExecutor() 建一个新的单线程 Executor 包起来。
注意没有关闭它——Executors.newSingleThreadExecutor() 创建的线程是非守护线程,
理论上会阻止 JVM 退出。但测试 JVM 会被 Gradle/Maven 强杀,所以实际无影响。
待核实:这个 Executor 是否在测试结束后被关闭(是否泄漏线程)本轮未确认。 可自测:跑大量 HtmlUnit 测试观察线程数是否增长。
10.4 SecurityWebTestClientAutoConfiguration:只有五行
@AutoConfiguration
@ConditionalOnClass(SecurityMockServerConfigurers.class)
public final class SecurityWebTestClientAutoConfiguration {
@Bean
MockServerConfigurer mockServerConfigurer() {
return SecurityMockServerConfigurers.springSecurity();
}
}
这是全系列里见过的最小的自动配置类——一个 bean,一行实现。
它注册 MockServerConfigurer 供 WebTestClient 使用,
让响应式测试也能带上 Spring Security 上下文。
值得注意的不是代码,而是它为什么能这么短:因为所有复杂度都在
spring-security-test 侧(SecurityMockServerConfigurers.springSecurity()),
Spring Boot 只做"把它注册成 bean"这一件事。
这正是自动配置的理想形态:框架能自动推断的就自动做, 剩下的交给用户或上游库。 第 14 章讲自动配置的设计原则时强调过这一点。
11. OAuth2 与 SAML2:五个模块的规模对比
安全相关的模块一共六个,主模块之外还有五个:
| 模块 | main 源文件数 | 自动配置数 |
|---|---|---|
spring-boot-security | 33 | 9 |
spring-boot-security-oauth2-resource-server | 27 | 4 |
spring-boot-security-oauth2-client | 13 | 4 |
spring-boot-security-oauth2-authorization-server | 8 | 2 |
spring-boot-security-saml2 | 6 | 1 |
spring-boot-security-test | 2 | 0(走 imports 贡献) |
三个观察:
一、spring-boot-security 的 main 源文件只有 33 个,但覆盖面极广
(Servlet + Reactive + RSocket + Actuator)。因为它大量使用"条件 + 委托":
Spring Boot 只写"什么时候启用什么",真正的实现全在 Spring Security 侧。
二、oauth2-resource-server 有 27 个源文件但只有 4 个自动配置。
因为资源服务器涉及 JWT 解码、不透明令牌introspection、多种 key 来源,
有大量辅助类(属性类、条件类、自定义 JwtDecoder 实现)。
三、spring-boot-security-test 只有 2 个 Java 类,却有 3 个资源文件。
这个比例(2 : 3)是全仓库最"配置密集"的模块之一——
它几乎没有代码,全靠资源文件与上游集成。
这印证了第 3 章的一个判断:Spring Boot 4 的模块化方向是"代码下沉到模块,配置上浮到资源文件"。
spring-boot-security-test 是这条路走到极端的例子。
待核实:五个 OAuth2/SAML2 模块的具体自动配置实现本轮未逐个读取 (资源规模所限,本章聚焦主模块与安全集成机制)。 可自测:分别读
OAuth2ResourceServerAutoConfiguration、OAuth2ClientAutoConfiguration、Saml2RelyingPartyAutoConfiguration。
12. 全景回顾:一百条定律的十张地图
地图一 · "答案藏在哪里"(阅读源码的定位能力)
这一组回答"某个行为到底是谁决定的,该从哪儿读起"。
本章最典型的一处:spring.security.user.password 的默认值不在 SecurityAutoConfiguration 里,
而在 SecurityProperties.User 的字段初始化上——private String password = UUID.randomUUID().toString()(§2)。
顺着"谁读这个字段"往上找,才会走到 UserDetailsServiceAutoConfiguration 与它的条件。
定位能力的本质,是知道默认值写在哪一类文件里。
第一定律:先找
*.imports,再找类(第 1 章) 第二定律:条件注解就是自动配置的文档(第 1 章) 第三定律:用测试回答"它到底做了什么"(第 1 章) 第四定律:顺序语义常藏在 Javadoc / 文档里,不在代码里(第 2 章) 第十定律:诊断能力是"提前布局"出来的,不是事后加的(第 5 章) 第十四定律:三目表达式的"守卫方向"要按真值表读,不要按直觉读(第 17 章) 第三十四定律:BeanDefinition的 attribute 可当"来源标记"(第 23 章) 第六十定律:把规范链接写进代码(第 26 章) 第六十二定律:约定优于配置走到极致时,把约定写进注释(第 27 章) 第八十六定律:环境探测的最后手段是栈扫描,但必须配 classpath 前置检查(第 29 章)
地图二 · 顺序与优先级
这一组回答"两处配置都成立时,谁先生效"。
本章的例子是过滤器链的匹配次序:securityMatcher 指定的 matcher 先被问,
ApplicationContextRequestMatcher 把"要不要问"这件事推迟到第一次请求(§8)——
于是"配置的先后"在配置期是看不出来的,只有请求到达时才定序。
当顺序被延迟到运行期,配置期看到的顺序就是假的。
第五定律:逆序插入 = "后声明者优先"(第 3 章) 第九定律:输入顺序不可控,而结果顺序有语义时,中间必有一次"归一化排序"(第 4 章) 第二十九定律:外部信息覆盖内部配置 = BPP 排在属性绑定之后(第 22 章) 第三十二定律:条件相同的两个自动配置,胜负由
before/after决定(第 23 章) 第三十五定律:双向dependsOn写入依赖 detector 顺序来避免成环(第 23 章) 第三十六定律:基础设施的初始化时机由"它要为谁服务"决定(第 24 章) 第五十五定律:跨模块排序用beforeName/afterName(第 26 章) 第五十六定律:Binder 的绑定必须延迟到单例就绪(第 26 章) 第五十九定律:标准环境变量的映射要"带来源 + 可整体关闭 + 优先级高于配置文件"(第 26 章)
地图三 · 条件与退让
这一组回答"什么条件下该退让、退让给谁"。
本章给出了教科书式的一对:MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured
跑在 PARSE_CONFIGURATION 阶段,而 DefaultWebSecurityCondition 跑在 REGISTER_BEAN 阶段(§5)——
同样"没有用户自定义链才生效"的语义,因为要看的东西不同,被拆成了两个不同 phase 的条件。
退让条件的写法,取决于它需要看到什么。
第十三定律:SPI 化的判断依据是"核心模块是否知道"(第 17 章) 第二十五定律:条件里的"phase"决定条件能否正确工作(第 21 章) 第三十三定律:
null是"用户没配"的信号(第 23 章) 第四十二定律:端点存在 ≠ 端点可用 ≠ 端点暴露(第 25 章) 第四十三定律:include与exclude的空集语义不对称(第 25 章) 第四十六定律:@ConditionalOnMissingBean(name = ...)是按名判定,可用于"两个自动配置抢同一个名字"(第 25 章) 第五十四定律:@ConditionalOnXxx起语义化名字,让条件自我解释(第 26 章) 第九十九定律:@ConditionalOnMissingBean要覆盖所有可能的替身类型(第 30 章) 第一百定律:懒加载与"必须早期实例化"的冲突,用排除过滤器解决(第 30 章)
地图四 · 可选依赖
这一组回答"依赖不在 classpath 上时怎么办"。
本章的样本是 spring-boot-security-test:整个模块只有 2 个 Java 类 + 3 个资源文件(§10),
靠 META-INF/spring/...@WebMvcTest.imports 这种纯资源贡献把自己接进切片测试,
类本体反而可以缺席。可选依赖的最高形态是"只贡献配置,不贡献代码"。
第十九定律:按名字协作的地方,条件就按名字写(第 19 章) 第二十定律:
afterName+@Import= 可选依赖的标准写法(第 19 章) 第二十六定律:可选依赖的能力探测用"字符串类名"(第 21 章) 第六十六定律:可选依赖之间用"反射按名调用"建立约定(第 27 章) 第七十七定律:可选依赖的守卫必须用字符串类名(第 28 章) 第八十八定律:白名单里的可选条目用字符串类名 + try/catch 加载(第 29 章)
地图五 · 兜底与降级
这一组回答"最坏情况发生时,是崩还是悄悄降级"。
本章有两处降级都偏危险:明文密码被自动加上 {noop} 前缀而不报错(§2),
以及默认用户没有任何角色(§2)——两者都不会抛异常,只会让"看起来配好了"的应用
带着一个弱口令、零权限的账号上线。安全上的降级必须显式,不能静默。
第七定律:兜底实现有三重信号(第 4 章) 第十二定律:降级路径存在 = 这里不会崩,但可能静默偏离预期(第 6 章) 第十五定律:兜底责任归"调用方兜不住"的那一方(第 17 章) 第二十三定律:
@Bean返回 null 是"条件注册"的隐藏形式(第 20 章) 第五十二定律:给可选功能一个 NOOP 兜底 bean,下游就能无条件依赖(第 26 章) 第五十三定律:需要区分"谁建的"时,用一个空子类打类型标记(第 26 章) 第八十定律:产物是"必需品"还是"加速器",决定缺失时报错还是跳过(第 28 章) 第八十三定律:要统一接管某个行为,先剥夺默认行为(第 29 章) 第八十四定律:"什么都不做"用空实现表达,不用null(第 29 章) 第八十七定律:能力边界要显式拒绝,不要静默降级(第 29 章)
地图六 · 确定性
这一组回答"怎么让结果每次都一样"。
本章的例子是端点 ID:EndpointRequest.to("health") 里写的是端点 ID 而不是路径,
路径可以由用户改(management.endpoints.web.path-mapping.*),ID 不会(§9)。
把一个会变的量替换成一个不变的量,是获得确定性的最短路径。
第十一定律:
LinkedHashSet/TreeMap/ 手写序列化 = 构建确定性(第 5 章) 第四十九定律:宽容解析要配幂等写(第 25 章) 第五十七定律:全局静态状态必须由容器生命周期负责清理(第 26 章) 第七十九定律:同一份产物被多次写入时,要么内容一致,要么直接失败(第 28 章) 第九十四定律:多文件原子更新需要静默期,而poll(timeout)天然提供它(第 30 章)
地图七 · 诊断与错误信息
这一组回答"出错时用户能看到什么"。 本章最值得记住的是反面例子:随机密码只在启动日志里出现一次, 且日志级别与格式取决于日志配置(§2);密码本身既不会 warn 也不会重复提示。 一条信息如果只在"最容易错过的地方"出现一次,等于没有。
第八定律:控制流用的哨兵异常,必须在错误处理链的第一行短路(第 4 章) 第十七定律:"阻止 JVM 退出"的线程是诊断信号(第 18 章) 第二十八定律:
getXxx与determineXxx的分离 = 诊断能力的地基(第 22 章) 第三十九定律:静默失效处必须有 WARN(第 24 章) 第四十八定律:慢路径只告警,不改变结果(第 25 章) 第五十定律:把反射失败提前转成可读错误(第 25 章) 第五十一定律:一个埋点,多个出口(第 26 章) 第五十八定律:基数限制要"只告警一次 + 直接拒绝"(第 26 章) 第八十九定律:失败信息要自包含——先把上下文捞出来再抛(第 29 章) 第九十定律:约定驱动的属性名要写清代价(第 29 章)
地图八 · 性能与并发
这一组回答"高频路径上怎么省掉开销、又不出错"。
本章的 ApplicationContextRequestMatcher(§8)是标准答案:matches() 声明为 final,
内部用 volatile boolean initialized + synchronized 做双重检查锁定,
把"从容器里取 bean"这件只能在运行期做的事只做一次。
配置期拿不到参数的对象,就用延迟初始化 + 一次性缓存来补。
第三十一定律:构造参数若会触发容器查询(getBeansOfType / getBeanNamesForType),必须包装成 Supplier 延迟求值,否则会把"配置期"变成"实例化期",制造过早初始化(第 23 章) 第六十一定律:把"能不能不用"的判断放在最早的地方(第 27 章) 第六十三定律:跨层传递"调用方意图"用 ThreadLocal,但必须 try/finally 限定(第 27 章) 第六十四定律:高频失败路径上用共享异常实例(第 27 章) 第六十五定律:负缓存与正缓存同样重要(第 27 章) 第六十七定律:并行化的代价是必须写"容忍竞态"的代码(第 27 章) 第六十八定律:与 JDK 的坑共存要三层——预防、补救、恢复(第 27 章) 第六十九定律:复刻 JDK 的算法可以换掉对象分配(第 27 章)
地图九 · AOT 与构建期
这一组回答"哪些事必须在构建期做完"。
本章没有新增例证——安全自动配置没有专属的 AOT 处理器,
它靠的是第 27–28 章讲的通用机制:AutoConfiguration.imports 在构建期被读取与校验,
运行期(AOT 模式)直接消费。没有专属逻辑本身就是一种设计结论:
能在通用机制里解决的,就不长专用代码。
第二十一定律:AOT 的贡献 = "把运行时决策提前到编译期并硬编码"(第 19 章) 第四十一定律:AOT 下把"解析结果"序列化,而不是保留原始输入(第 24 章) 第七十定律:运行期约束必须在构建期有对应的产出(第 27 章) 第七十一定律:跨框架边界用字符串契约,不用类型引用(第 28 章) 第七十二定律:录制者不消费录制结果(第 28 章) 第七十三定律:判断能力差异要看执行环境,不看模式开关(第 28 章) 第七十四定律:AOT 生成代码的目标是省掉"决策",不是省掉"执行"(第 28 章) 第七十五定律:
processAheadOfTime必须能快速返回null(第 28 章) 第七十六定律:生成代码之后必须自己注册可达性 hint(第 28 章) 第七十八定律:序列化解析结果前,先确认能与运行时环境解耦(第 28 章)
地图十 · API 与抽象设计
这一组回答"抽象该长成什么样"。
本章给了两个方向:ApplicationContextRequestMatcher<C> 用泛型参数把"上下文类型"留在签名里(§8),
UserDetailsService 则只暴露一个方法、把"用户从哪来"整个留给用户(§2)——
前者是"抽象保留差异",后者是"抽象放弃差异"。
决定抽象形态的,是这个差异点未来会不会变。
第六定律:语义的"作用域"常藏在构造参数里(第 10 章) 第十六定律:契约写在 Javadoc 里,实现用"奇怪代码"兑现(第 18 章) 第十八定律:多实现的差异点 = 底层 API 能力差(第 18 章) 第二十二定律:平行实现的不对称处 = 历史包袱或底层约束(第 20 章) 第二十四定律:Boot 只注册"配置对象"= 功能内建在框架里(第 20 章) 第二十七定律:配置是 Map 时,用 accumulator 而非 setter(第 21 章) 第三十定律:
destroyMethod = ""标记"所有权边界"(第 22 章) 第三十七定律:跨框架的传参用对方的 context 容器(第 24 章) 第三十八定律:两套等价实现是技术债(第 24 章) 第四十定律:隐式升级比显式声明友好(第 24 章) 第四十四定律:跨容器协作时,先定"谁持有 bean",再定"谁持有映射"(第 25 章) 第四十五定律:子容器要"关掉自动探测,改用约定 bean 名显式注册"(第 25 章) 第四十七定律:默认值写在代码里会分裂,写在属性里才统一(第 25 章) 第八十一定律:无法静态配置的规则,做成 bean 去查(第 29 章) 第八十二定律:基类无法给出相等语义时,用抛异常强制子类实现(第 29 章) 第八十五定律:三态枚举用两个正交布尔表达(第 29 章) 第九十一定律:抽象要为未来留余地——持有"集合"而不是"单个"(第 30 章) 第九十二定律:高层 API 是 final 时,去包装它依赖的底层抽象(第 30 章) 第九十三定律:互斥状态用取反定义,不要用两个独立判断(第 30 章) 第九十五定律:扩展点用"回调改可变设置对象",而不是"继承覆盖方法"(第 30 章) 第九十六定律:属性默认值从下游抽象推导,不自己写死(第 30 章) 第九十七定律:"0 / 1 / 多"三分支处理扩展点集合(第 30 章) 第九十八定律:注册表把读写接口分开(第 30 章)
13. 数字全景:三十一章走过了什么
数据来自对本仓库的实际统计(find / grep / wc),不是估算。
13.1 仓库规模
| 指标 | 数值 | 说明 |
|---|---|---|
module/ 子模块数 | 128 | Spring Boot 4 的模块化拆分结果 |
core/ 子模块数 | 8 | 含 spring-boot、spring-boot-autoconfigure 等 |
| main 源文件数 | 5,085 | *.java,不含测试 |
| test 源文件数 | 3,151 | 测试代码量与主代码量之比约 0.62 |
@AutoConfiguration 类数 | 360 | 全仓库标注数 |
AutoConfiguration.imports 文件数 | 100 | |
| imports 文件条目总数 | 323 | 平均每个文件 3.2 条,最多的模块有几十条 |
@ConfigurationProperties 文件数 | 190 | 含嵌套属性类所在文件 |
@ConditionalOn* 注解种类 | 21 | 在 autoconfigure/condition 包下 |
几个观察:
一、360 个自动配置类但只有 323 条 imports 条目。 差额来自:
(a)部分自动配置类通过 spring.factories 或其它键注册(如 ManagementContextConfiguration);
(b)同一个类在多个 imports 文件里出现(如 SecurityAutoConfiguration 同时在主模块和
security-test 的 imports 里);
(c)测试侧的自动配置不计入。
二、测试代码 3,151 个文件 vs 主代码 5,085 个。 比例 0.62, 对于框架级项目来说属于偏低——但这只是文件数,Spring Boot 的测试文件普遍很长 (集成测试动辄几百行)。
三、128 个 module 是 Spring Boot 4 最显著的结构变化。
Spring Boot 3.x 时代 spring-boot-autoconfigure 是一个巨大的 jar,
4.x 把它按技术栈拆成了 128 个模块。这个数字直接决定了:
依赖可以精确到"只用 @WebMvcTest 就只引入 spring-boot-webmvc-test"。
13.2 本系列规模
| 指标 | 数值 |
|---|---|
| 章节数 | 31 |
| 卷数 | 8 |
| 累计字符数 | 约 290 万(第 31 章完成后) |
| 累计行数 | 约 83,000 |
| 提炼定律数 | 110(第 31 章新增 10 条) |
八卷划分:
| 卷 | 章 | 主题 |
|---|---|---|
| 一 | 01–04 | 坐标系、构建体系、模块化、启动全景 |
| 二 | 05–11 | 配置:属性、Environment、ConfigData、Binder、@ConfigurationProperties、Profile、非文件配置源 |
| 三 | 12–17 | 上下文:事件、bean 定义、自动配置引擎、条件注解、排序与报告、应用类型 |
| 四 | 18–21 | Web:内嵌服务器、MVC、WebFlux、gRPC |
| 五 | 22–23 | 数据访问:JDBC/JPA |
| 六 | 24–26 | 运维:日志、Actuator、指标与链路 |
| 七 | 27–28 | 交付:可执行归档、AOT 与 Native |
| 八 | 29–31 | 进阶与收官:测试、基础设施、安全与回顾 |
卷二(配置)有七章,是全系列最大的一卷——因为配置是 Spring Boot 最独特、
也最没有对应物的部分:其它框架没有 ConfigData、没有 Binder、没有 relaxed binding。
反过来卷五只有两章,因为 JDBC/JPA 的自动配置相对薄(真正的复杂度在 Hibernate 里)。
13.3 Spring Boot 3 → 4:本系列亲眼看到的结构性变化
本系列只针对 4.1.1 这一个版本,但读的过程中不断撞上"这里以前不是这样"的痕迹。 下面六条全部有源码证据(不是迁移文档的转述):
一、spring.factories → *.imports。
自动配置、测试切片、ManagementContextConfiguration 全部改用
META-INF/spring/<接口全名>.imports(第 3 章、§10.1)。
好处是"一个键一个文件",格式更简单,且能被 checkAutoConfigurationImports 校验。
二、包名从 ...autoconfigure.web 拆到具体技术栈。
本章源码里看到的:org.springframework.boot.webmvc.autoconfigure.DispatcherServletPath、
org.springframework.boot.webmvc.test.autoconfigure.MockMvcAutoConfiguration、
org.springframework.boot.security.autoconfigure.*。
Spring Boot 3 里这些都在 org.springframework.boot.autoconfigure.* 下。
拆分后包名直接对应模块名——webmvc 包就在 spring-boot-webmvc 模块里。
三、切片测试注解从 spring-boot-test-autoconfigure 拆到各技术栈模块。
第 29 章统计过:module/ 下有 24 个 *-test 模块
(spring-boot-webmvc-test、spring-boot-data-jpa-test、spring-boot-security-test…)。
Spring Boot 3 里 @WebMvcTest 在 spring-boot-test-autoconfigure 里。
四、Health 从 actuate.health 移到 health.actuate.endpoint。
本章 §6.1 的 ClassUtils.isPresent("org.springframework.boot.health.actuate.endpoint.HealthEndpoint", ...)
是这条变化的最直接证据——health 提到了 actuate 前面,
因为它在 4.x 里先于 Actuator 成为一个独立的基础模块(spring-boot-health),
Actuator 端点只是它的一个消费方。
这个包路径变化在 §6.1 里是以字符串出现的(因为是可选依赖守卫),
所以编译器不会报错——如果写成 HealthEndpoint.class,升级时就会编译失败,
反而是好事。这是字符串守卫的一个隐藏代价。
五、模块数从几十涨到 128。 §13.1 统计过。带来的直接变化是"依赖可以精确到单个技术栈", 代价是"找东西要先确定它在哪个模块"。
六、TypeIncludes 机制被显式化。
META-INF/spring/<注解全名>.includes 文件(§10.1)用于声明
"切片扫描时要额外纳入哪些类型"。Spring Boot 3 里这个需求是通过
TypeExcludeFilter 的内部列表硬编码解决的,
4.x 把它外置成资源文件,第三方 jar 才能贡献——
这正是 spring-boot-security-test 能让 @WebMvcTest 扫到 SecurityFilterChain 的原因。
待核实:上述变化的确切引入版本(3.x 的哪一版、4.0 还是 4.1) 本系列未逐个查证(只针对 4.1.1 读源码)。 可自测:对比
spring-boot-3.5.x与4.1.1的同名文件路径。
13.4 三十一章的一句话总结
| 章 | 一句话 |
|---|---|
| 01 | 模块拓扑与版本矩阵是读源码的第一张地图 |
| 02 | Gradle 约定插件把"每个模块该长什么样"固化成代码 |
| 03 | imports 机制取代 spring.factories,Starter 由构建生成 |
| 04 | SpringApplication.run 是一个十二步状态机 |
| 05 | ApplicationProperties 是 spring.main.* 的统一载体 |
| 06 | Environment 的 PropertySource 次序决定了配置优先级 |
| 07 | ConfigData 是一棵 contributor 树,profile 迭代在树上做 |
| 08 | Binder 把字符串变成对象,全程可回溯 |
| 09 | @ConfigurationProperties 的全生命周期:注册、绑定、校验、AOT |
| 10 | Profile 语义与多文档解析 |
| 11 | 非文件配置源:ConfigTree、环境变量映射 |
| 12 | 事件总线与 Bootstrap 上下文、CRaC |
| 13 | bean 定义加载与组件扫描 |
| 14 | AutoConfigurationImportSelector 全流程 |
| 15 | 全部 @ConditionalOnXxx 的实现原理 |
| 16 | 配置元数据、条件报告、失败分析 |
| 17 | 应用类型推断与上下文工厂 |
| 18 | 内嵌 Web 服务器抽象 |
| 19 | Spring MVC 自动配置与 Jackson 3 |
| 20 | WebFlux 与 Reactor Netty |
| 21 | gRPC 服务端与客户端 |
| 22 | 数据源、连接池、事务 |
| 23 | JPA 与 Hibernate |
| 24 | 日志系统与结构化日志 |
| 25 | Actuator 端点与健康 |
| 26 | Micrometer 与 OpenTelemetry |
| 27 | 可执行归档与 JarLauncher |
| 28 | AOT 与 GraalVM Native |
| 29 | 测试体系与 Testcontainers |
| 30 | SSL Bundle、虚拟线程、Retry |
| 31 | Spring Security 与全景回顾 |
14. 决策速查表
把本章(以及前三十章)里最容易被问到的"该怎么做"列成表。 每一项都有源码依据,不是经验之谈。
14.1 安全配置
| 你想做什么 | 怎么做 | 依据 |
|---|---|---|
| 改默认用户名/密码 | spring.security.user.name/password | §2.3(空串会被忽略) |
| 给默认用户加角色 | spring.security.user.roles=ADMIN,USER | §3.5(默认是空,用户无角色) |
| 用自己的认证 | 注册任意 UserDetailsService / AuthenticationManager bean | §3.1(类级 @ConditionalOnMissingBean) |
| 改过滤器顺序 | spring.security.filter.order(默认 -100) | §5.3 |
| 限制过滤器作用的 dispatcher | spring.security.filter.dispatcher-types | §5.4 |
| 放行静态资源 | PathRequest.toStaticResources().atCommonLocations() | §8.2 |
| 放行某个 Actuator 端点 | EndpointRequest.to(XxxEndpoint.class) | §7.1 |
| 放行 H2 控制台 | PathRequest.toH2Console() | §8.4 |
| 测试里带用户 | @WithMockUser(需 spring-boot-security-test) | §10.2 |
| 切片测试带 Security | 加 spring-boot-security-test 依赖即可 | §10.1(靠 imports 文件) |
让自定义 SecurityFilterChain 被 @WebMvcTest 扫到 | 同上(靠 .includes 文件) | §10.1 |
14.2 五个"框架不会报错"的坑
这五条是三十一章里最值得单独记住的——它们都不会抛异常、不会打警告, 只会在某个时刻给你一个错误的结果。
- 虚拟线程模式下
spring.task.execution.pool.*静默失效(第 30 章)。 虚拟线程不需要池,配了也没用,但不会有任何提示。 - 未开
verify-keys时,私钥与证书不匹配也能启动(第 30 章)。 SSL Bundle 默认不校验密钥与证书是否配对。 - 懒加载下
@Scheduled方法有特殊例外处理(第 30 章)。 需要排除过滤器的配合。 - 加了自定义
SecurityFilterChain后,health 端点突然要认证(本章 §6.5)。ManagementWebSecurityAutoConfiguration因为@ConditionalOnDefaultWebSecurity一起退让了。 - 默认用户没有任何角色(本章 §3.5)。
hasRole("ADMIN")对默认用户永远为 false,但不会报错。
14.3 上线前的安全自查清单
按"忘了会怎样"排序,全部来自本章源码:
- 改掉了默认密码吗? 启动时如果还有
Using generated security password这行WARN日志,说明还在用随机密码(§2.4)。 - 默认用户有角色吗? 默认是没有角色的(§3.5)。
如果你的配置里用了
hasRole(...),默认用户永远过不去—— 这看起来像"配置写错了",实际是"用户没角色"。 - 密码是明文吗? 若
spring.security.user.password没有{...}前缀 且没有PasswordEncoderbean,会被自动加{noop}(§3.3)—— 这个过程没有任何警告。 - health 端点能被探针访问吗? 一旦你提供了自己的
SecurityFilterChain,ManagementWebSecurityAutoConfiguration会整体退让(§6.5), health 端点就开始要认证了。需要自己写requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()。 - 用了额外路径吗? 如果配了
management.endpoints.web的额外路径, 只放行EndpointRequest.to(HealthEndpoint.class)不够, 还要toAdditionalPaths(WebServerNamespace.SERVER, HealthEndpoint.class)(§6.3)。 - H2 控制台放行了没有? 如果生产依赖里有 H2 且被自动配置启用, 控制台路径是不放行的(§8.4)——这是对的,但要确认它是否该被彻底关掉。
- 过滤器覆盖了哪些 dispatcher type? 默认
EnumSet.allOf(DispatcherType.class)即全部(§5.3)。如果只想保护REQUEST,需要显式配置。
14.4 源码阅读的三条捷径
| 场景 | 捷径 |
|---|---|
| 想知道有哪些自动配置 | 读 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
| 想知道某个自动配置何时生效 | 读类上的条件注解(@ConditionalOnClass 的哨兵类 = 它要 new 的类) |
| 想知道顺序/默认值/已知限制 | 读类 Javadoc 与属性类的字段初始化表达式 |
15. 从本章提炼的定律
第一百零一定律:日志打在"行为发生的地方",不打在"数据存在的地方"。
随机密码的警告在 UserDetailsServiceAutoConfiguration.getOrDeducePassword 里(§2.4),
不在 SecurityProperties 里——因为只有前者知道"这个密码真的被使用了"。
放在属性类里会因多次实例化而重复输出。
第一百零二定律:字段初始化时生成的随机值,天然实现"每次启动都不同"。
private String password = UUID.randomUUID().toString();(§2.3)
不需要任何"是否已配置"的判断——先生成,再让 setter 覆盖,
用一个 passwordGenerated 布尔记录是否被覆盖过。
第一百零三定律:setter 对空值 return 而不是赋值,可以防住"配置成空"的安全漏洞。
if (!StringUtils.hasLength(password)) return;(§2.3)
没有这一行,spring.security.user.password=(空值,可能来自空环境变量)
会让应用以空密码启动。
第一百零四定律:两个关注点有不同的退让条件时,必须拆成两个自动配置。
SecurityFilterAutoConfiguration 与 ServletWebSecurityAutoConfiguration 分开的原因(§1):
用户自定义安全时后者退让,但"过滤器顺序"仍然需要被配置。
放在一个类里只能取交集,拆开后各自独立退让。
第一百零五定律:@ConditionalOnMissingBean 的 value 用 Class、type 用字符串,
分界线是"这个类是否一定存在"。
value = { AuthenticationManager.class, ... } 是必选依赖,
type = "org.springframework.security.oauth2.jwt.JwtDecoder" 是可选依赖(§3.1)。
写成 JwtDecoder.class 会让配置类本身在依赖缺失时 NoClassDefFoundError。
第一百零六定律:ConfigurationPhase 以"最严格的条件"为准,不是以"第一个条件"为准。
DefaultWebSecurityCondition 既有 @ConditionalOnClass(PARSE 可判)
又有 @ConditionalOnMissingBean(必须 REGISTER),整体选 REGISTER_BEAN(§4.2)。
反过来,MissingAlternativeUserDetailsManagerOrUserPropertiesConfigured
三个分支全是非 bean 条件,选 PARSE_CONFIGURATION(§3.2)。
第一百零七定律:延迟初始化 + 双重检查锁定 + volatile 是"配置期创建、运行期才知道参数"
的标准解法。
ApplicationContextRequestMatcher.matches() 是 final(§7.2),
子类只能实现 ignoreApplicationContext / initialized / matches。
上下文用 Supplier 传递,使得"被忽略"的路径根本不会取上下文。
第一百零八定律:构造失败时返回"永不匹配"的哨兵,胜过抛异常阻断启动。
EMPTY_MATCHER = (request) -> false(§7.9)。
安全配置失败导致应用起不来,比"health 端点需要认证"糟糕得多。
判断标准同第八十定律:这个东西是必需品还是加速器。
第一百零九定律:安全相关的默认值必须是"最严格"的,且要能显式放行。
Reactive 侧的 denyAllAuthenticationManager(§9.1)在没有认证组件时让认证失败;
H2 控制台必须显式调用 PathRequest.toH2Console() 才放行(§8.4)。
危险的便利必须由用户明确选择。
第一百一十定律:模块的价值不一定在代码量,可能在资源文件。
spring-boot-security-test 只有 2 个 Java 类却有 3 个资源文件(§11),
正是这 3 个文件让"加依赖即生效"成为可能。
Spring Boot 4 的方向是代码下沉到模块,配置上浮到资源文件。
16. 本章未逐行核实 / 待你实测的项
按"核实难度"从低到高排列。前五项在本仓库内可自行验证。
-
@ConditionalOnMissingBean返回实现类时的匹配基准(§2.2):authenticationEventPublisher()返回DefaultAuthenticationEventPublisher, 条件类型写的是接口AuthenticationEventPublisher.class。 是否按接口匹配未逐行确认OnBeanCondition的实现。 自测:注册一个自定义的AuthenticationEventPublisher(非DefaultAuthenticationEventPublisher子类), 看默认 bean 是否仍注册。 -
两个 order 相同的
SecurityFilterChain的选择行为(§4.4):ManagementWebSecurityAutoConfiguration与ServletWebSecurityAutoConfiguration都用BASIC_AUTH_ORDER。是否取决于 bean 注册顺序未确认。 自测:启用 Actuator + Security,断点看FilterChainProxy内部 chain 的顺序。 -
@ConditionalOnClass(DispatcherServletPath.class)是否冗余(§4.5): 它与@ConditionalOnBean(DispatcherServletPath.class)并存, 后者已隐含类存在。未找到必须性证据。 -
SessionCreationPolicy作为哨兵的理由(§5.2): 本类不使用它。更精确的哨兵可能是别的类。未见于注释。 -
Servlet 侧
warn与 Reactive 侧info的日志级别不一致(§2.4、§9.2): 推测是历史遗留。自测:分别跑两种应用对比启动日志。 -
ClassUtils.isPresent是否有内部缓存(§7.6): 它在matches()路径上被调用,若无缓存则是性能隐患。 该类在 Spring Framework 里,不在本仓库。 -
spring-security-test的SecurityMockMvcRequestPostProcessors.testSecurityContext()的具体搬运逻辑(§10.2):本轮未读取(spring-security-test不在本仓库)。 -
HtmlUnit
DelegatingSecurityContextExecutor的线程池是否泄漏(§10.3):Executors.newSingleThreadExecutor()创建的非守护线程未见关闭。 自测:跑大量 HtmlUnit 测试观察线程数。 -
五个 OAuth2 / SAML2 模块的自动配置实现(§11): 本章只统计了规模,未逐个读实现。 自测:读
OAuth2ResourceServerAutoConfiguration、OAuth2ClientAutoConfiguration、Saml2RelyingPartyAutoConfiguration。 -
managementPortType字段未加volatile的可见性影响(§7.4): 推测无害(枚举类型 + 值恒定),但未找到明确注释。 -
imports文件的行序是否完全无意义(§10.1):AutoConfigureMockMvc.imports里的条目不是字母序。 第 14 章的判断是"行序不保证执行顺序",但本轮未做实验验证。 -
@ConditionalOnDefaultWebSecurity为何未在 Reactive 侧复用(§9.5): 推测是类型不兼容(SecurityFilterChainvsSecurityWebFilterChain), 但源码无注释说明。
17. 终章:三十一章之后,你手上多了什么
17.1 三样可迁移的能力
写这三十一章的过程里,真正有长期价值的不是"记住了 Spring Boot 的某个类", 而是三样可以迁移到任何大型 Java 框架的能力:
一、定位能力。 拿到一个陌生模块,能在十分钟内找到:
它有哪些自动配置(读 imports)、什么条件下生效(读条件注解)、
与其它模块的先后关系(读 before/after)、已知限制(读 Javadoc)。
这套动作在 Spring Boot 的 128 个模块上通用,在别的大型框架上也大体通用。
二、读"沉默代码"的能力。 框架里最有信息量的代码往往是不显眼的:
一个字段初始化表达式、String.format 里的 %n%n、一个常量减 5、
一个 if (x == null) return;、一个空 lambda。
三十一章里被反复标注的"框架不会报错的坑"(§14.2),
全部藏在这类沉默代码里。能看到它们,才能避免凌晨三点的线上事故。
三、区分"规律"与"规则"的能力。 本系列提炼了 110 条定律,
但每一条都标注了出处与反例可能。比如第八十八定律(白名单用字符串类名)
与第七十七定律(可选依赖守卫用字符串类名)说的是同一件事的两个场景;
而 §7.6 里 ClassUtils.isPresent 未做缓存,恰恰说明
第六十五定律(负缓存同样重要)是"观察到的规律"而不是"强制的规则"。
真实代码里总有权衡与遗漏——定律是地图,不是法律。
17.2 本系列的三个刻意坚持
回顾三十一章,有三件事是贯穿始终、刻意坚持的:
一、以源码为准。 每一条结论都能指到具体的类、方法、行。 凡是本轮未读到的,一律标"待核实"并给出自测方法—— 三十一章累计标注了约 400 条待核实项。这些标注不是缺陷, 反而是本系列最诚实的部分:知道自己的边界,比假装无所不知更有用。
二、不把推断当证据。 源码里没有注释说明的动机,一律写"推测"二字。
比如 §7.4 的 managementPortType 为何不加 volatile、
§9.5 的 Reactive 侧为何不复用 @ConditionalOnDefaultWebSecurity、
§2.3 的 hasLength 为何不是 hasText——这些都标了推测。
推测可以被推翻,但被标了"推测"的推测不会误导人。
三、每章八万到十万字符,不注水。 这个下限逼出了很多本来不会写的内容——
第 26 章初稿只有 77k,为了补到 80k 以上又回去读了三个自动配置类,
结果发现了 SimpleMetricsExportAutoConfiguration 的星形拓扑、
两个 tracing bridge 的不对称、Testcontainers 的连接详情。
字数下限在这里起的作用,是防止"写到差不多就停"。
17.3 从这里继续往哪里走
如果你读完三十一章还想继续深入,三个方向:
方向一:横向补齐。 本系列有若干"统计了规模但未逐个读"的模块——
OAuth2 四件套(本章 §11)、spring-boot-grpc(第 21 章)、
各数据模块的自动配置(卷五)。这些可以按同样的读法补齐。
方向二:纵向挖到 Spring Framework。 本系列止步于 Spring Boot 的边界,
但很多机制的底座在 spring-core / spring-context 里:
Binder 的底层是 PropertyEditor + ConversionService,
AutoConfigurationImportSelector 的底层是 DeferredImportSelector,
RuntimeHints 的底座在 spring-core 的 aot 包。
本系列里标了"在 Spring Framework 里,不在本仓库"的地方,都是入口。
方向三:自己动手验证。 三十一章里的约 400 条"待核实"项, 每一条都附了自测方法。挑感兴趣的跑一遍, 把结果补进对应章节——那会让这份文档从"一个人的阅读笔记" 变成"经多人验证的事实集"。
17.4 最后一句
Spring Boot 的代码量很大(5,085 个主源文件), 但它反复使用的机制并不多——三十一章提炼的 110 条定律, 本质上是在讲大约二十个核心机制的一百多种表现形态。
当你能在第 31 章的 ApplicationContextRequestMatcher 里看到
第 27 章 ZipContent 的"惰性 + 缓存"、
在 @ConditionalOnMissingBean(type = "...") 里看到
第 28 章 ClassUtils.isPresent 的"字符串守卫可选依赖"、
在 EMPTY_MATCHER 里看到第 28 章 __EnvironmentPostProcessor 的"缺失即跳过"——
这些机制就不再是"Spring Boot 的做法",而是"你自己的工具箱"。
那才是这三十一章真正想留下的东西。
全系列完
31 章 · 8 卷 · 约 290 万字符 · 110 条定律 · 约 400 条待核实项