SpringBoot
概念
随着spring整合越来越多的组件,手动配置变得繁琐困难,人称配置地狱,这是springboot出现的原因,springboot基于约定大于配置的思想,使得开发者可以以最少的配置运行起整合多个组件的应用,同时也支持深层次的自定义配置
springboot就是对原有spring进行封装增强的快速开发框架
Springboot启动器
原理浅谈
// 核心注解
@SpringBootApplication
@SpringBootConfiguration // springboot配置
@Configuration // java配置
@Component // spring组件
@EnableAutoConfiguration // 自动配置
@AutoConfigurationPackage // 自动配置包
@Import({AutoConfigurationPackages.Registrar.class}) // 自动包注册
@Import({AutoConfigurationImportSelector.class}) // 自动配置导入选择器
AutoConfigurationImportSelector // 这个类是自动配置的核心
selectImports //
getAutoConfigurationEntry // 获取自动配置
getCandidateConfigurations // 获取候选配置
ImportCandidates // 载入候选配置的类
load // 载入候选配置
AutoConfiguration // 带该注解
ClassLoader // 应用的类加载器
META-INF/spring/%s.imports // 特定文件名
getCandidates // 获取候选配置
@ComponentScan // 扫描启动类的同级包
// 总结: SpringBoot在启动时会通过应用的类加载器(ClassLoader)加载特定文件夹下特定文件名(META-INF/spring/%s.imports)且带有特定注解(AutoConfiguration)注解的配置类
// 扩展:AutoConfigurationImportSelector 继承自 ImportSelector, 在 SpringApplication.run() 执行时会间接执行 selectImports 方法
// 启动类
SpringApplication
getSpringFactoriesInstances // 获取配置文件
SpringFactoriesLoader // 配置文件加载器
forDefaultResourceLocation // 查询默认配置文件
ClassLoader // 应用的类加载器
META-INF/spring.factories // 配置文件名称
// 这种方式采用的是 spring.factories 文件
// 注:目前两种配置文件并行, 不同版本的过程不尽相同, 2.4以后在逐步向更加模块化的状态过渡
所有的官方 starter 的默认配置类都在 spring-boot-autoconfigure 这个 jar 包下
以 web 模块为例,存在 WebMvcAutoConfiguration 这个配置类,这个配置类则通过 WebProperties 和 WebMvcProperties 连接到 yml 文件或 properties 文件
例如:静态资源的路径设置默认为 WebProperties 中的 Resources 静态内部类的 CLASSPATH_RESOURCE_LOCATIONS 的"classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/"这四个文件夹
SpringApplication
TODO
1.推断项目类型
2.加载初始化器
3.加载监听器
4.找到应用主类
配置文件
配置文件有 properties文件和yml/yaml文件两种,推荐使用yml文件,更加简单易读,功能也更加强大
当存在同名两种配置文件时会优先加载properties文件
加载顺序
1.classpath 根目录:classpath:/application.properties
2.classpath 下的 /config 包:classpath:/config/application.properties
3.当前目录:./application.properties
4.当前目录下的 /config 子目录:./config/application.properties
注意:后加载的会覆盖先加载的,只覆盖相同的键,即如果后加载的只有部分键和先加载的相同,则先加载的其他键依然生效
当前路径/当前目录:指的都是项目所在文件夹
classpath/类路径:一个包含很多路径的集合(JVM根据它加载管理相关资源)
对于未打包的项目本身而言就是target下的test-classes目录和classes目录
一个打包好的jar包则仅指classes目录(springboot项目中是BOOT-INF/classes),test-classes不会被打包到jar包中
注解
@Value
// 用于单个映射, 只能一对一把配置文件的键值对映射到类字段
public @interface Value {
String value();
}
@ConfigurationProperties
// 用于结构化映射, 可以把指定前缀下的所有键值对映射到类
public @interface ConfigurationProperties {
@AliasFor("prefix")
String value() default "";
@AliasFor("value")
String prefix() default "";
boolean ignoreInvalidFields() default false;
boolean ignoreUnknownFields() default true;
}
@PropertySource
// 用于指定要加载的 properties 文件, 不支持 yml 文件
// 例如: @PropertySource("classpath:ssydx2.properties") 加载类路径下 ssydx2.properties 这个配置文件
// 不推荐自定义配置文件名, 最好还是采用默认, 如果有需求可以利用 application 这个主配置文件间接载入
// 也不推荐同时使用 properties 和 yml, 推荐单独使用 yml 文件
public @interface PropertySource {
String name() default "";
String[] value();
boolean ignoreResourceNotFound() default false;
String encoding() default "";
Class<? extends PropertySourceFactory> factory() default PropertySourceFactory.class;
}
@Value 和 @ConfigurationProperties
功能/特性 | @Value |
@ConfigurationProperties |
---|---|---|
注入方式 | 单个字段注入 | 整体 Bean 映射 |
配置来源 | 支持 properties/yml 中的任意 key | 支持 properties/yml 中的嵌套结构 |
是否支持嵌套对象 | ❌ 不支持 | ✅ 支持 |
是否支持 List/Set/Map | ⚠️ 需要特殊写法(如 SpEL 表达式) | ✅ 直接支持 |
是否需要 getter/setter | ❌ 不需要 | ✅ 需要 |
是否支持校验(JSR-303) | ❌ 不支持 | ✅ 可配合 @Validated 使用 |
是否需要注册为 Bean | ❌ 不需要 | ✅ 必须用 @Component 或 @Bean 注册 |
是否支持松散绑定(relaxed binding) | ❌ 不支持 | ✅ 支持(如 userName ↔ user-name ) |
是否支持元数据提示(IDE 提示) | ❌ 不支持 | ✅ 支持(需添加 spring-boot-configuration-processor ) |
是否适合复杂结构配置 | ❌ 不适合 | ✅ 非常适合 |
是否可重用 | ❌ 每次都要单独注入 | ✅ 可封装成统一配置类复用 |
是否支持前缀匹配 | ❌ 不支持 | ✅ 支持(通过 prefix = "xxx" ) |
是否支持 SpEL 表达式 | ✅ 强支持 | ❌ 基本不支持(仅限默认值等有限场景) |
性能 | ⚡ 更轻量(适用于少量属性) | ⬆ 略重(适用于结构化配置) |
使用难度 | ✅ 简单直接 | ✅ 略复杂但更规范 |
典型使用场景 | 注入单个简单属性(如端口、URL)、动态计算值 | 映射整个配置块(如数据库配置、第三方 API 配置) |
松散绑定:通常Java类字段采用小驼峰写法,而yml文件则采用烤串写法,虽然名称不完全相同但两者可以正确绑定
基本语法
application.yml
people:
name: ssydx1
age: ${random.int[20,30]} # 获取随机 int 整数
male: true
birthday: 1999/01/10
# map: {k1: v1,k2: v2} # map 的单行写法
map:
k1: v1
k2: v2
# list: [1,2,3,4,5] # list 的单行写法, array, set等同理
list:
- 1
- 2
- 3
- 4
- 5
# dog: {name: "${people.name}'s dog", age: 5} # 对象的单行写法, 本质就是 map
dog:
name: ${people.name}'s dog # 通过表达式使用已存在的键的值
age: ${people.dogage:5} # 提供属性不存在时的默认值
application.properties
# people 属性
people.name=ssydx2
people.age=${random.int[20,30]}
people.male=true
people.birthday=1999/01/10
# people.map
people.map.k1=v1
people.map.k2=v2
# people.list(注意:.properties 不支持列表结构,Spring Boot 会自动解析为 List)
people.list[0]=1
people.list[1]=2
people.list[2]=3
people.list[3]=4
people.list[4]=5
# people.dog
people.dog.name=${people.name}'s dog
people.dog.age=${people.dogage:5}
yml 和 properties
特性/功能 | .properties 文件 |
.yml / .yaml 文件 |
---|---|---|
文件格式类型 | 键值对(Key-Value Pair) | 树状结构化数据(YAML 格式) |
是否支持嵌套结构 | ❌ 不支持原生嵌套,需通过点号 . 表示逻辑嵌套 |
✅ 原生支持多层嵌套结构 |
是否支持集合类型(List/Set) | ⚠️ 可以用索引方式模拟(如 key[0]=a ) |
✅ 原生支持列表写法(如 - item1 ) |
是否支持 Map 结构 | ⚠️ 可以用多个键名模拟 | ✅ 原生支持嵌套键值对 |
语法可读性 | ✅ 简单直观,适合少量配置 | ✅ 更适合复杂、层次清晰的配置 |
注释符号 | # |
# |
大小写敏感 | ✅ 是(Spring 配置统一大小写敏感) | ✅ 是 |
默认加载文件名 | application.properties |
application.yml |
Spring Boot 加载顺序优先级 | 同等优先级(按 profile 加载) | 同等优先级(按 profile 加载) |
SpEL 表达式支持(在配置中直接写) | ❌ 不支持(不能在 .properties 中直接使用 SpEL) |
❌ 不支持(不能在 .yml 中直接使用 SpEL) |
SpEL 表达式使用方式 | ✅ 支持在 Java 注解或 XML 中使用 SpEL 引用配置项(如 @Value("#{...}") ) |
✅ 同上 |
是否支持占位符引用 ${} |
✅ 支持(如 ${app.name} ) |
✅ 支持(如 ${app.name} ) |
常见用途 | ✅ 简单配置、历史项目兼容 | ✅ 复杂结构配置、微服务、新项目推荐 |
缩进敏感 | ❌ 否 | ✅ 是(缩进错误会导致解析失败) |
IDE 支持程度 | ✅ 广泛且成熟 | ⚠️ 某些 IDE 对 YAML 的提示和校验不如 properties 成熟 |
配置覆盖机制(Profile) | ✅ 支持(通过 application-{profile}.properties ) |
✅ 支持(通过 application-{profile}.yml ) |
JSR-303
JSR-303指的是 Bean Validation API 的标准规范,常用实现则是 Hibernate Validator
不同版本情况不同,早期版本如果导入 Web starter,则已经包含 Hibernate Validator 这个依赖,无需单独导入,较新版本可能需要单独导入
在类字段上添加以下注解,在绑定时会进行校验
注解 | 描述 |
---|---|
@NotNull |
字段不能为空 |
@Null |
字段必须为 null |
@Size(min=, max=) |
字符串长度或集合大小在指定范围内 |
@Min(value) |
数字值不得小于指定值 |
@Max(value) |
数字值不得大于指定值 |
@Pattern(regexp) |
字符串必须匹配给定的正则表达式 |
@Email |
字符串应为合法的电子邮件地址格式 |
@AssertTrue / @AssertFalse |
必须为 true 或 false |
多环境配置
单独子配置文件
存在 application-server-dev.yml、application-server-pro.yml、application-server-banner.yml、application.yml(这个是主配置文件) 这四个配置文件
# 主配置文件
spring:
profiles:
group:
# 分组
dev:
# 单独子配置文件要以 application- 为前缀
- server-dev
- banner
# 分组
pro:
- server-pro
- banner
# 激活指定分组, 也可以激活特定配置文件
active: dev
内嵌子配置文件
spring:
profiles:
group:
# 分组
dev:
- server-dev
- banner
# 分组
pro:
- server-pro
- banner
# 激活指定分组, 也可以激活特定配置文件
active: dev
---
# 子配置文件
# 内嵌子配置文件, 通过这种方式命名, 无需 application- 前缀
spring:
config:
activate:
on-profile: server-dev
server:
port: 9098
---
spring:
config:
activate:
on-profile: server-pro
server:
port: 9099
---
spring:
config:
activate:
on-profile: banner
main:
banner-mode: off
如何配置?
配置文件中可配置的选项数量十分庞大,且在不断更新,不建议全部记忆,实际上这些选项都有对应的属性类和配置类,进行配置时可参考对应的属性类和配置类
以 Redis 配置为例
RedisAutoConfiguration
@AutoConfiguration
// 如果类路径下存在 RedisOperations 这个类(这个类在spring-boot-starter-data-redis 依赖中)
@ConditionalOnClass({RedisOperations.class})
// 启用 RedisProperties 这个属性类, 本质就是把它注册为 Bean
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
@Bean
// 条件注册, 如果自己注册了名为 redisTemplate 的 Bean, 此处将不再进行注册
@ConditionalOnMissingBean(
name = {"redisTemplate"}
)
// 条件注册, 如果存在单个 RedisConnectionFactory 类型的 Bean, 才会进行注册
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(RedisConnectionFactory.class)
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new StringRedisTemplate(redisConnectionFactory);
}
}
RedisProperties
// 省略了 getter 和 setter 相关代码
@ConfigurationProperties(
prefix = "spring.redis"
)
// 这里列出了所有可在配置文件中配置的选项
public class RedisProperties {
private int database = 0;
private String url;
private String host = "localhost";
private String username;
private String password;
private int port = 6379;
private boolean ssl;
private Duration timeout;
private Duration connectTimeout;
private String clientName;
private ClientType clientType;
private Sentinel sentinel;
private Cluster cluster;
private final Jedis jedis = new Jedis();
private final Lettuce lettuce = new Lettuce();
public static enum ClientType {
LETTUCE,
JEDIS;
}
public static class Pool {
private Boolean enabled;
private int maxIdle = 8;
private int minIdle = 0;
private int maxActive = 8;
private Duration maxWait = Duration.ofMillis(-1L);
private Duration timeBetweenEvictionRuns;
}
public static class Cluster {
private List<String> nodes;
private Integer maxRedirects;
}
public static class Sentinel {
private String master;
private List<String> nodes;
private String username;
private String password;
}
public static class Jedis {
private final Pool pool = new Pool();
}
public static class Lettuce {
private Duration shutdownTimeout = Duration.ofMillis(100L);
private final Pool pool = new Pool();
private final Cluster cluster = new Cluster();
public static class Cluster {
private final Refresh refresh = new Refresh();
public static class Refresh {
private boolean dynamicRefreshSources = true;
private Duration period;
private boolean adaptive;
}
}
}
}
如何查看哪些配置类生效?
# 通过开启 debug 模式可以看到应用启动时哪些配置类生效
debug: true
什么时候配置类生效(配置的 Bean)?
通过 @Conditional 系列注解进行判断,这些注解会判断是否满足条件(如:classpath类路径下是否存在指定的类,IOC 容器(应用上下文)中是否存在特定的 Bean等),进而决定是否启用并注册相关的 Bean,如果载入了对应的 starter 就会启用对应的配置类,但配置类的 Bean 是否注册还要进一步根据是否存在用户自定义的 Bean 等条件进行综合判断
条件注解
注解 | 判断内容 | 示例 |
---|---|---|
@ConditionalOnClass |
类路径中是否存在指定类 | @ConditionalOnClass(RedisOperations.class) |
@ConditionalOnMissingClass |
类路径中不存在指定类 | @ConditionalOnMissingClass("com.example.SomeClass") |
@ConditionalOnBean |
容器中是否存在某个类型的 Bean | @ConditionalOnBean(DataSource.class) |
@ConditionalOnMissingBean |
容器中不存在某个类型的 Bean | @ConditionalOnMissingBean(MyService.class) |
@ConditionalOnProperty |
配置文件中存在某个属性且值为 true | @ConditionalOnProperty(prefix = "my", name = "enabled", havingValue = "true") |
@ConditionalOnResource |
指定资源是否存在(如 classpath:/xxx.properties) | @ConditionalOnResource(resources = "classpath:my-config.xml") |
@ConditionalOnWebApplication |
是否是 Web 应用 | @ConditionalOnWebApplication |
@ConditionalOnNotWebApplication |
是否不是 Web 应用 | @ConditionalOnNotWebApplication |
@Profile |
当前激活的 profile 是指定值 | @Profile("dev") |
Web
静态资源
webjars
webjars是前端资源的封装,如:jquery 等封装为 jar 包,通过在 pom 载入依赖可进行访问
不常用,该目录本质就是 /META-INF/resources/
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.7.1</version>
</dependency>
访问路径为/webjars/jquery/3.7.1/jquery.js
访问顺序
注意:classpath:/resources/ 指的是 /resources/resources/ !!!
classpath:/META-INF/resources/ classpath:/resources/ classpath:/static/ classpath:/public/
当存在同名的静态资源时,会按照以上顺序查找,返回首个查找到的资源,通常只会使用 classpath:/static/
源码分析
// WebMvcAutoConfiguration
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 如果设置 add-mappings: false 将取消一切静态资源映射
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
// 否则按以下规则
} else {
// 注册一个从 /webjars/** 映射到 classpath:/META-INF/resources/webjars/ 的资源处理器
this.addResourceHandler(registry, "/webjars/**", "classpath:/META-INF/resources/webjars/");
// 注册一个从 /** 映射到 {"classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/"} 的资源处理器
this.addResourceHandler(registry, this.mvcProperties.getStaticPathPattern(), (Consumer)((registration) -> {
registration.addResourceLocations(this.resourceProperties.getStaticLocations());
if (this.servletContext != null) {
ServletContextResource resource = new ServletContextResource(this.servletContext, "/");
registration.addResourceLocations(new Resource[]{resource});
}
}));
}
}
// WebMvcProperties
private String staticPathPattern = "/**";
// WebProperties
public static class Resources {
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = new String[]{"classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/"};
private String[] staticLocations;
private boolean addMappings;
private boolean customized;
private final Chain chain;
private final Cache cache;
public Resources() {
this.staticLocations = CLASSPATH_RESOURCE_LOCATIONS;
this.addMappings = true;
this.customized = false;
this.chain = new Chain();
this.cache = new Cache();
}
}
配置文件
spring:
mvc:
# 如果这样设置, 意味着访问静态资源目录的文件都需要额外添加该前缀
static-path-pattern: /hello/**
web:
resources:
# 如果这样设置, 意味着只能通过访问 public 这个静态资源目录
static-locations: classpath:/public/
# 如果这样设置, 意味着取消一切静态资源映射
add-mappings: false
Web 首页
源码分析
// WebMvcAutoConfiguration
// 首页处理映射器
@Bean
public WelcomePageHandlerMapping welcomePageHandlerMapping(ApplicationContext applicationContext, FormattingConversionService mvcConversionService, ResourceUrlProvider mvcResourceUrlProvider) {
return (WelcomePageHandlerMapping)this.createWelcomePageHandlerMapping(applicationContext, mvcConversionService, mvcResourceUrlProvider, WelcomePageHandlerMapping::new);
}
private <T extends AbstractUrlHandlerMapping> T createWelcomePageHandlerMapping(ApplicationContext applicationContext, FormattingConversionService mvcConversionService, ResourceUrlProvider mvcResourceUrlProvider, WelcomePageHandlerMappingFactory<T> factory) {
TemplateAvailabilityProviders templateAvailabilityProviders = new TemplateAvailabilityProviders(applicationContext);
String staticPathPattern = this.mvcProperties.getStaticPathPattern();
T handlerMapping = factory.create(templateAvailabilityProviders, applicationContext, this.getIndexHtmlResource(), staticPathPattern);
handlerMapping.setInterceptors(this.getInterceptors(mvcConversionService, mvcResourceUrlProvider));
handlerMapping.setCorsConfigurations(this.getCorsConfigurations());
return handlerMapping;
}
// 查找首页资源
private Resource getIndexHtmlResource() {
// 在静态资源目录遍历
for(String location : this.resourceProperties.getStaticLocations()) {
Resource indexHtml = this.getIndexHtmlResource(location);
if (indexHtml != null) {
return indexHtml;
}
}
ServletContext servletContext = this.getServletContext();
if (servletContext != null) {
return this.getIndexHtmlResource((Resource)(new ServletContextResource(servletContext, "/")));
} else {
return null;
}
}
private Resource getIndexHtmlResource(String location) {
return this.getIndexHtmlResource(this.resourceLoader.getResource(location));
}
private Resource getIndexHtmlResource(Resource location) {
try {
// 文件名称为 index.html
Resource resource = location.createRelative("index.html");
if (resource.exists() && resource.getURL() != null) {
return resource;
}
} catch (Exception var3) {
}
return null;
}
由上可知,首页文件必须名为index.html,且需要在静态资源目录(作为直接子文件)下
注意:静态资源包括首页,是直接返回给客户端的,不经过视图解析器
即:即使你借助 Thymeleaf 的语法对 html 静态资源进行增强,也不会产生效果,静态资源必须静态!
注:图标文件 favicon.ico 放置在静态资源目录会被自动识别作为网站图标
模板引擎Thymeleaf
命名空间
<!-- 引入命名空间 --> <html xmlns:th="http://www.thymeleaf.org">
th增强
属性名 | 示例 | 作用 |
---|---|---|
th:text |
<p th:text="${name}"> |
设置文本内容,并自动转义 HTML |
th:utext |
<p th:utext="${htmlContent}"> |
不转义 HTML,直接输出原始内容 |
th:if |
<div th:if="${condition}"> |
条件渲染:如果为真才渲染该元素 |
th:unless |
<div th:unless="${user.loggedIn}"> |
和 th:if 相反,为假时渲染 |
th:switch / th:case |
<div th:switch="${user.role}"> <span th:case="'admin'">Admin</span> |
多条件判断(类似 switch-case) |
th:each |
<tr th:each="user : ${users}"> |
遍历集合、数组等 |
th:value |
<input th:value="${age}"> |
设置 input 的 value 值 |
th:href |
<a th:href="@{/user/{id}(id=${user.id})}"> |
动态生成 URL,支持路径参数 |
th:src |
<img th:src="@{/images/logo.png}"> |
动态设置图片路径 |
th:id |
<div th:id="'user-' + ${user.id}"> |
动态设置 id 属性 |
th:name |
<input th:name="${fieldName}"> |
动态设置 name 属性 |
th:class |
<div th:class="${active ? 'active' : ''}"> |
动态设置 class |
th:classappend |
<div class="base" th:classappend="${active ? 'active' : ''}"> |
在原有 class 上追加新 class |
th:style |
<div th:style="'color:'+${color}"> |
动态设置 style 样式 |
th:include |
<div th:include="fragments/header :: header"> |
引入模板片段(保留父结构) |
th:replace |
<div th:replace="fragments/header :: header"> |
替换当前结构为指定片段 |
th:remove |
<div th:remove="${condition}?tag"> |
移除标签或内容(根据条件) |
th:action |
<form th:action="@{/submit}"> |
设置 form 表单提交地址 |
th:selected |
<option th:selected="${user.gender == 'M'}"> |
控制 option 是否选中 |
th:checked |
<input type="checkbox" th:checked="${user.rememberMe}" /> |
控制 checkbox 是否选中 |
th:placeholder |
<input th:placeholder="${'请输入'+field}" /> |
动态设置 placeholder 文本 |
th:onclick |
<button th:onclick="'alert(\'' + ${msg} + '\')'"> |
动态设置 onclick 事件 |
th:attr |
<div th:attr="data-id=${id}, title='test'"> |
设置任意属性值(适合非标准属性) |
th:readonly |
<input th:readonly="${isReadOnly}" /> |
动态设置只读属性 |
th:disabled |
<button th:disabled="${disableButton}"> |
动态设置禁用状态 |
th:alt |
<img th:alt="#{default.alt.text}" /> |
动态设置 alt 属性 |
th:title |
<span th:title="${tooltip}">Hover me</span> |
动态设置提示文字 |
th:lang |
<html th:lang="${languageCode}"> |
设置语言代码 |
th:xml:lang |
<html th:xml:lang="${languageCode}"> |
设置 XML 语言代码 |
th:fragment |
<div th:fragment="header"> |
定义可复用的模板片段 |
th:inline |
<script th:inline="javascript"> |
启用内联脚本/样式(如 JS 变量注入) |
表达式
表达式类型 | 语法格式 | 示例 | 描述 |
---|---|---|---|
变量表达式 | ${...} |
${user.name} |
获取上下文变量值 |
选择表达式 | *{...} |
*{name} |
在 th:object 上下文中获取属性 |
消息表达式 | #{...} |
#{home.title} |
国际化消息(i18n),从 .properties 文件取值 |
链接表达式 | @{...} |
@{/user/{id}(id=${user.id})} |
动态生成 URL 地址 |
条件表达式 | (if) ? (then) : (else) |
${user.isAdmin() ? '管理员' : '普通用户'} |
三元运算符 |
默认值表达式 | ?: |
${name} ?: '默认值' |
如果前面的值为 null,则使用默认值 |
表达式预处理 | __${...}__ |
__${project.version}__ |
在模板解析前进行替换(高级) |
内置对象
对象名 | 示例 | 描述 |
---|---|---|
#ctx |
${#ctx.locale} |
访问上下文对象 |
#locale |
${#locale.getLanguage()} |
获取当前 Locale |
#request |
${#request.getAttribute('attr')} |
获取 HTTP 请求属性 |
#session |
${#session.getAttribute('user')} |
获取 Session 属性 |
#dates |
${#dates.format(now, 'yyyy-MM-dd')} |
日期格式化工具 |
#calendars |
${#calendars.day(now)} |
日历相关操作 |
#numbers |
${#numbers.formatDecimal(price, 2, 'COMMA')} |
数字格式化 |
#strings |
${#strings.isEmpty(name)} |
字符串工具类 |
#arrays |
${#arrays.length(list)} |
数组工具 |
#lists |
${#lists.size(list)} |
List 工具 |
#sets |
${#sets.size(set)} |
Set 工具 |
#maps |
${#maps.size(map)} |
Map 工具 |
国际化设置
源码分析
// WebMvcAutoConfiguration
@Bean
// 只有当不存在名为 localeResolver 的 Bean 时才生效
@ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME)
public LocaleResolver localeResolver() {
// 如果配置的解析器是 FixedLocaleResolver 则使用 FixedLocaleResolver 进行处理, 默认是 AcceptHeaderLocaleResolver
if (this.webProperties.getLocaleResolver() == WebProperties.LocaleResolver.FIXED) {
// 处理配置的 Locale, 默认无
return new FixedLocaleResolver(this.webProperties.getLocale());
}
// 使用 AcceptHeaderLocaleResolver 解析器处理配置的 Locale
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(this.webProperties.getLocale());
return localeResolver;
}
// FixedLocaleResolver
public Locale resolveLocale(HttpServletRequest request) {
Locale locale = this.getDefaultLocale();
// 如果未配置 Locale 则获取 Java 默认语言 props.getProperty("user.language", "en");
if (locale == null) {
locale = Locale.getDefault();
}
return locale;
}
// AcceptHeaderLocaleResolver
// 过程涉及很多步骤, 总而言之 supportedLocale > requestLocale > defaultLocale
public Locale resolveLocale(HttpServletRequest request) {
// Step 1: 获取配置中定义的默认 Locale(比如 application.yml 或 Java Config 中设置的)
Locale defaultLocale = this.getDefaultLocale();
// Step 2: 如果请求头中没有 Accept-Language(即客户端未指定语言偏好)
// 并且有默认语言,则直接使用默认语言
if (defaultLocale != null && request.getHeader("Accept-Language") == null) {
return defaultLocale;
} else {
// Step 3: 使用 Servlet API 自动解析出客户端首选的 Locale
// 这个 locale 是根据 Accept-Language 头和服务器内置支持的语言综合匹配出来的
Locale requestLocale = request.getLocale();
// Step 4: 获取你自定义的支持语言列表(通过 setSupportedLocales 设置的)
List<Locale> supportedLocales = this.getSupportedLocales();
// Step 5: 如果你配置了支持语言列表,并且当前请求的 locale 不在其中
if (!supportedLocales.isEmpty() && !supportedLocales.contains(requestLocale)) {
// 尝试从你支持的语言列表中找一个“最接近”的 locale(比如 zh_TW -> zh_CN)
Locale supportedLocale = this.findSupportedLocale(request, supportedLocales);
// Step 6: 如果找到了一个匹配的支持语言,就返回它
if (supportedLocale != null) {
return supportedLocale;
} else {
// Step 7: 如果没找到任何支持的语言,就使用默认语言(如果有的话)
// 否则兜底使用原始解析出的 locale(即使不在支持列表里也返回)
return defaultLocale != null ? defaultLocale : requestLocale;
}
} else {
// Step 8: 如果请求的 locale 已经在你支持的列表中,那就直接返回它
return requestLocale;
}
}
}
// MessageSourceProperties 消息源属性类
// MessageSourceAutoConfiguration 消息源自动配置类
配置文件
spring:
messages:
# 本地化文件所在位置
basename: i18n.login
web:
# defaultLocale
locale: en_US
# 默认的 LocaleResolver
locale-resolver: accept-header
本地化文件
# login.properties
login.btn=登录
login.password=密码
login.remember=记住我
login.tip=请登录
login.username=用户名
# login_en_US.properties
login.btn=login
login.password=password
login.remember=Remeber Me
login.tip=Please Sign in
login.username=username
# login_zh_CN.properties
login.btn=登录
login.password=密码
login.remember=记住我
login.tip=请登录
login.username=用户名
在开发时可以显式设置 accept-language(浏览器默认为 zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7)或,否则难以看出本地化效果
或者通过自定义一个 LocaleResolver,覆盖默认的解析器(注意 Bean 名称必须是 localeResolver),在请求时根据参数进行语言切换
格式化设置
// WebMvcAutoConfiguration
@Bean
@Override
public FormattingConversionService mvcConversionService() {
Format format = this.mvcProperties.getFormat();
WebConversionService conversionService = new WebConversionService(
new DateTimeFormatters().dateFormat(format.getDate())
.timeFormat(format.getTime())
.dateTimeFormat(format.getDateTime()));
addFormatters(conversionService);
return conversionService;
}
// WebMvcProperties
// 也就是说默认只能解析以下格式的日期, 可以自行修改, 但始终不能解析多种不同的日期格式
public static class Format {
/**
* Date format to use, for example 'dd/MM/yyyy'.
*/
private String date;
/**
* Time format to use, for example 'HH:mm:ss'.
*/
private String time;
/**
* Date-time format to use, for example 'yyyy-MM-dd HH:mm:ss'.
*/
private String dateTime;
}
ORM
spring-boot-starter-jdbc
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/yourdb
driver-class-name: com.mysql.cj.jdbc.Driver
username: yourname
password: yourpassword
DataSource
Spring Data 家族的 SQL ORM 默认都使用 Hikari 数据源(2.x起)
package xyz.ssydx.springbootwithjdbc;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.sql.DataSource;
import java.sql.*;
@SpringBootTest
class SpringBootWithJdbcApplicationTests {
@Autowired
private DataSource dataSource;
@Test
void contextLoads() throws SQLException {
// 默认使用 Hikari
System.out.println("dataSource===>" + dataSource);
System.out.println("dataSource.loginTimeout===>" + dataSource.getLoginTimeout());
Connection connection = dataSource.getConnection();
System.out.println("dataSource.connection===>" + connection);
System.out.println("connection.metaData===>" + connection.getMetaData());
System.out.println("connection.clientInfo===>" + connection.getClientInfo());
System.out.println("connection.catalog===>" + connection.getCatalog());
System.out.println("connection.schema===>" + connection.getSchema());
System.out.println("connection.autoCommit===>" + connection.getAutoCommit());
Statement statement = connection.createStatement();
System.out.println("connection.statement===>" + statement);
ResultSet resultSet1 = statement.executeQuery("select * from user_info");
System.out.println("resultSet===>" + resultSet1);
while (resultSet1.next()) {
System.out.println(
resultSet1.getInt(1)
+ " " + resultSet1.getString("name")
+ " " + resultSet1.getInt("age")
+ " " + resultSet1.getBoolean("gender")
+ " " + resultSet1.getDate("birthday")
);
}
resultSet1.close();
statement.close();
PreparedStatement preparedStatement = connection.prepareStatement("select * from user_info where id = ?");
System.out.println("connection.preparedStatement===>" + preparedStatement);
preparedStatement.setInt(1, 2);
ResultSet resultSet2 = preparedStatement.executeQuery();
while (resultSet2.next()) {
System.out.println(
resultSet2.getInt(1)
+ " " + resultSet2.getString("name")
+ " " + resultSet2.getInt("age")
+ " " + resultSet2.getBoolean("gender")
+ " " + resultSet2.getDate("birthday")
);
}
resultSet2.close();
preparedStatement.close();
connection.close();
}
}
UserController
package xyz.ssydx.springbootwithjdbc.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("user/")
public class UserController {
@Autowired
private JdbcTemplate jdbcTemplate;
@RequestMapping("/list")
public List<Map<String, Object>> list() {
String sql = "select * from user_info";
List<Map<String, Object>> maps = jdbcTemplate.queryForList(sql);
return maps;
}
@RequestMapping("/query/{id}")
public Map<String, Object> query(@PathVariable("id") int id) {
String sql = "select * from user_info where id = ?";
Map<String, Object> map = jdbcTemplate.queryForMap(sql, id);
return map;
}
@RequestMapping("/add")
public String add() {
String sql = "insert into user_info(id, name, age, gender, birthday) values(?,?,?,?,?)";
Object[] params = { 4, "zhaoliu", 38, true, "2024/12/12 12:12:12" };
jdbcTemplate.update(sql, params);
return "success";
}
@RequestMapping("/del")
public String del() {
String sql = "delete from user_info where id = ?";
jdbcTemplate.update(sql, 4);
return "success";
}
@RequestMapping("/update")
public String update() {
String sql = "update user_info set name=?,age=?,gender=?,birthday=? where id=?";
jdbcTemplate.update(sql, "qianqi", 38, false, "2025/12/12 12:12:12", 4);
return "success";
}
}
spring-boot-starter-data-jdbc
依赖配置等类同 jdbc
User
package xyz.ssydx.springbootwithjdbc.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
// 指定表名
@Table("user_info")
public class User {
// 指定主键
@Id
private int id;
private String name;
private int age;
private boolean gender;
private Date birthday;
}
UserDao
package xyz.ssydx.springbootwithjdbc.dao;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import xyz.ssydx.springbootwithjdbc.pojo.User;
@Repository
public interface UserDao extends CrudRepository<User, Integer> {
// 自定义插入
@Modifying
@Query("INSERT INTO user_info (id, name, age, gender, birthday) VALUES (:id, :name, :age, :gender, :birthday)")
void insert(@Param("id") Integer id,
@Param("name") String name,
@Param("age") int age,
@Param("gender") boolean gender,
@Param("birthday") java.util.Date birthday);
}
UserController
package xyz.ssydx.springbootwithjdbc.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import xyz.ssydx.springbootwithjdbc.dao.UserDao;
import xyz.ssydx.springbootwithjdbc.pojo.User;
import java.util.Date;
import java.util.List;
@RestController
@RequestMapping("user2/")
public class UserController {
@Autowired
private UserDao userDao;
@RequestMapping("/list")
public List<User> list() {
return (List<User>) userDao.findAll();
}
@RequestMapping("/query/{id}")
public User query(@PathVariable("id") int id) {
return userDao.findById(id).get();
}
@RequestMapping("/add")
public String add() {
// CrudRepository 不支持带主键插入, 会识别为更新操作, 需要自定义方法
userDao.insert(4, "zhaoliu", 38, true, new Date());
return "success";
}
@RequestMapping("/del")
public String del() {
userDao.deleteById(4);
return "success";
}
@RequestMapping("/update")
public String update() {
userDao.save(new User(4, "qianqi", 38, false, new Date()));
return "success";
}
}
spring-boot-starter-data-jpa
依赖配置等类同 jdbc
User
package xyz.ssydx.springbootwithdatajpa.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@Table(name = "user_info")
@Entity
public class User {
@Id
private int id;
private String name;
private int age;
private boolean gender;
private Date birthday;
}
UserDao
package xyz.ssydx.springbootwithdatajpa.dao;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import xyz.ssydx.springbootwithdatajpa.pojo.User;
@Repository
public interface UserDao extends JpaRepository<User, Integer> {
}
UserController
package xyz.ssydx.springbootwithdatajpa.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import xyz.ssydx.springbootwithdatajpa.pojo.User;
import xyz.ssydx.springbootwithdatajpa.dao.UserDao;
import java.util.Date;
import java.util.List;
@RestController
@RequestMapping("user/")
public class UserController {
@Autowired
private UserDao userDao;
@RequestMapping("/list")
public List<User> list() {
return userDao.findAll();
}
@RequestMapping("/query/{id}")
public User query(@PathVariable("id") int id) {
return userDao.findById(id).get();
}
@RequestMapping("/add")
public String add() {
User user = new User(4, "zhaoliu", 38, true, new Date());
// 支持带主键插入, 无需单独设置 SQL 语句
userDao.save(user);
return "success";
}
@RequestMapping("/del")
public String del() {
userDao.deleteById(4);
return "success";
}
@RequestMapping("/update")
public String update() {
User user = new User(4, "qianqi", 38, false, new Date());
userDao.save(user);
return "success";
}
}
mybatis-spring-boot-starter
依赖
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>
配置
mybatis:
# 指定实体类包, 方便编写 mapper 文件时省略包名
type-aliases-package: xyz.ssydx.springbootwithmybatis.pojo
# mapper 文件所在目录
mapper-locations: classpath:mybatis/mapper/*.xml
User
package xyz.ssydx.springbootwithmybatis.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class User {
private int id;
private String name;
private int age;
private boolean gender;
private Date birthday;
}
UserMapper
package xyz.ssydx.springbootwithmybatis.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
import xyz.ssydx.springbootwithmybatis.pojo.User;
import java.util.List;
@Mapper
@Repository
public interface UserMapper {
List<User> findAll();
User findById(int id);
int insert(User user);
int update(User user);
int deleteById(int id);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xyz.ssydx.springbootwithmybatis.mapper.UserMapper">
<select id="findAll" resultType="User">
select * from user_info;
</select>
<select id="findById" resultType="User" parameterType="int">
select * from user_info where id = #{id};
</select>
<insert id="insert" parameterType="User">
insert into user_info(id, name, age, gender, birthday) VALUES (#{id}, #{name},#{age}, #{gender}, #{birthday});
</insert>
<update id="update" parameterType="User">
update user_info set name=#{name},age=#{age},gender=#{gender},birthday=#{birthday} where id=#{id};
</update>
<delete id="deleteById" parameterType="int">
delete from user_info where id=#{id};
</delete>
</mapper>
UserController
package xyz.ssydx.springbootwithmybatis.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import xyz.ssydx.springbootwithmybatis.mapper.UserMapper;
import xyz.ssydx.springbootwithmybatis.pojo.User;
import java.util.Date;
import java.util.List;
@RestController
@RequestMapping("user/")
public class UserController {
@Autowired
private UserMapper userMapper;
@RequestMapping("/list")
public List<User> list() {
return userMapper.findAll();
}
@RequestMapping("/query/{id}")
public User query(@PathVariable("id") int id) {
return userMapper.findById(id);
}
@RequestMapping("/add")
public String add() {
User user = new User(4, "zhaoliu", 38, true, new Date());
userMapper.insert(user);
return "success";
}
@RequestMapping("/del")
public String del() {
userMapper.deleteById(4);
return "success";
}
@RequestMapping("/update")
public String update() {
User user = new User(4, "qianqi", 38, false, new Date());
userMapper.update(user);
return "success";
}
}
DataSource
常见数据源对比
特性/指标 | HikariCP | Druid | C3P0 | Apache DBCP | Tomcat JDBC CP |
---|---|---|---|---|---|
性能 | 非常高,号称最快的Java连接池 | 良好,但稍逊于HikariCP | 较低,相对较慢 | 中等 | 良好,接近HikariCP |
获取/关闭连接速度 | 快 | 相对较慢 | 慢 | 中等 | 快 |
Statement缓存 | 不支持 | 支持PSCache | 支持 | 支持 | 支持 |
监控功能 | 基础监控支持(JMX) | 强大的监控功能,包括Web控制台 | 基础监控支持 | 基础监控支持 | 基础监控支持 |
SQL拦截 | 不支持 | 支持 | 不直接支持,但可通过扩展实现 | 不支持 | 不支持 |
代码量 | 简洁的实现 | 中等大小 | 大量 | 中等 | 中等 |
扩展性 | 较弱 | 良好 | 良好 | 中等 | 良好 |
配置复杂度 | 简单 | 中等 | 复杂 | 中等 | 简单到中等 |
社区活跃度 | 高 | 高 | 低 | 中等 | 中等到高 |
数据库兼容性 | 广泛支持多种数据库 | 广泛支持多种数据库 | 广泛支持多种数据库 | 广泛支持多种数据库 | 广泛支持多种数据库 |
默认集成 | Spring Boot默认使用的连接池 | 需要手动配置或使用其他框架支持 | 需要手动配置 | 需要手动配置 | 需要手动配置 |
额外特性 | - | 内置监控页面、安全增强 | 内置连接测试、自动重连 | - | 配置简单,易于使用 |
最常见的是 Hikari(默认数据源)和 Druid
Hikari
TODO
Druid
依赖
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.5</version>
</dependency>
配置
一旦添加了依赖就会导致数据源变为 Druid,甚至无需进行 Druid 的配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/yourdb
driver-class-name: com.mysql.cj.jdbc.Driver
username: yourusername
password: yourpassword
源码分析
// DataSourceAutoConfiguration
@Configuration(
proxyBeanMethods = false
)
@Conditional({PooledDataSourceCondition.class})
@ConditionalOnMissingBean({DataSource.class, XADataSource.class})
// 这是优先使用 Hikari 的原因(顺序)之一
@Import({DataSourceConfiguration.Hikari.class, DataSourceConfiguration.Tomcat.class, DataSourceConfiguration.Dbcp2.class, DataSourceConfiguration.OracleUcp.class, DataSourceConfiguration.Generic.class, DataSourceJmxConfiguration.class})
protected static class PooledDataSourceConfiguration {
}
// DataSourceConfiguration
@Configuration(
proxyBeanMethods = false
)
// 如果存在 HikariDataSource 这个类, 实例上 Data 家族底层都包含了 Hikari 依赖, 该类总是存在
// 这也是 数据源默认总是 Hikari 的原因之一
@ConditionalOnClass({HikariDataSource.class})
// 如果不存在 DataSource 类型的 Bean, 生效
@ConditionalOnMissingBean({DataSource.class})
// 如果配置文件存在 spring.datasource.type=com.zaxxer.hikari.HikariDataSource 或 不存在该键值对, 生效
// 这意味着如果设置了 spring.datasource.type 这个键值对, 只有对应的 静态类 能够匹配. 只会加载对应的数据源
@ConditionalOnProperty(
name = {"spring.datasource.type"},
havingValue = "com.zaxxer.hikari.HikariDataSource",
matchIfMissing = true
)
// 其他数据源同理
static class Hikari {
@Bean
@ConfigurationProperties(
prefix = "spring.datasource.hikari"
)
HikariDataSource dataSource(DataSourceProperties properties) {
HikariDataSource dataSource = (HikariDataSource)DataSourceConfiguration.createDataSource(properties, HikariDataSource.class);
if (StringUtils.hasText(properties.getName())) {
dataSource.setPoolName(properties.getName());
}
return dataSource;
}
}
// DataSourceProperties
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean {
private ClassLoader classLoader;
private boolean generateUniqueName = true;
private String name;
private Class<? extends DataSource> type;
private String driverClassName;
private String url;
private String username;
private String password;
private String jndiName;
}
// 那么为什么一旦载入 Druid 的依赖就默认生效, 甚至无需配置 spring.datasource.type 这个键值对?
// 翻看源码即可知
// DruidDataSourceAutoConfigure
@Configuration
@ConditionalOnClass({DruidDataSource.class})
// 这个注解意为 在 DataSourceAutoConfiguration 配置前优先配置
// 即 Druid 会在数据源自动配置执行前先配置一个数据源, 这样自动配置发现存在数据源 Bean, 自然不会再进行配置
@AutoConfigureBefore({DataSourceAutoConfiguration.class})
@EnableConfigurationProperties({DruidStatProperties.class, DataSourceProperties.class})
@Import({DruidSpringAopConfiguration.class, DruidStatViewServletConfiguration.class, DruidWebStatFilterConfiguration.class, DruidFilterConfiguration.class})
public class DruidDataSourceAutoConfigure {
private static final Logger LOGGER = LoggerFactory.getLogger(DruidDataSourceAutoConfigure.class);
@Bean(
initMethod = "init"
)
@ConditionalOnMissingBean
public DataSource dataSource() {
LOGGER.info("Init DruidDataSource");
return new DruidDataSourceWrapper();
}
}
如何才能指定使用哪个数据源,甚至使用多个数据源?
1.配置文件中显式指定数据源类型(无法处理 Druid 这类三方数据源的加载问题)
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
2.排除特定数据源依赖(可以处理 Druid 这类三方数据源的加载问题)
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.2</version>
<exclusions>
<exclusion>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</exclusion>
</exclusions>
</dependency>
3.禁用数据源的配置类(可以处理 Druid 这类三方数据源的加载问题)
spring:
autoconfigure:
exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
4.自定义数据源的Bean(可以处理 Druid 这类三方数据源的加载问题),最强大最灵活但也最复杂
package xyz.ssydx.springbootwithdruid.config;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DataSourceConfig {
@Bean
public HikariDataSource hikariDataSource() {
return new HikariDataSource();
}
}
配置类的加载顺序是什么样的?
自定义的配置类 早于 自动配置类(自动配置类又可分为官方配置类和三方配置类,两者可借助 AutoConfigureBefore 和 AutoConfigureAfter 强制更改顺序)
数据源配置类使用哪个用户名和密码?
spring:
datasource:
url: jdbc:mysql://localhost:3306/yourdb
driver-class-name: com.mysql.cj.jdbc.Driver
username: yourusername
password: correctpassword
# 对于 DataSourceProperties(spring.datasource.*) 中:
# private String driverClassName;
# private String url;
# private String username;
# private String password;
# druid 优先使用自己 DruidDataSourceWrapper(spring.datasource.druid.*) 中的, 如果没有才会使用 DataSourceProperties
# 具体逻辑参见 DruidDataSourceAutoConfigure、DruidDataSourceWrapper
# 不难通过测试验证这一点, 只需分别设置两次用户密码, datasource.password 设置为正确的, druid.password 设置为错误的
# 结果是访问数据库失败
druid:
password: errorpassword
配置文件
spring:
datasource:
druid:
# 监控台显示的数据源名称
name: druidDataSource
# 初始连接数
initial-size: 5
# 最小空闲连接数
min-idle: 10
# 最大连接数
max-active: 20
# 等待空闲连接的超时时间
max-wait: 60000
# 创建新连接时的超时时间
connect-timeout: 30000
# 执行SQL操作时(已经获取了可用连接)的超时时间
socket-timeout: 60000
# 检测连接的时间间隔
time-between-eviction-runs-millis: 60000
# 连接保持空闲的最短时间
min-evictable-idle-time-millis: 300000
# 连接保持空闲的最长时间
max-evictable-idle-time-millis: 900000
# 检测连接的SQL语句
validation-query: SELECT 1 FROM DUAL
# 空闲时是否测试连接有效性
test-while-idle: true
# 从连接池获取连接时是否测试连接有效性
test-on-borrow: false
# 归还连接到连接池时是否测试连接有效性
test-on-return: false
# 对于SQL语句数量多或内存不足的情况,应设置较小的缓存数量或不设置缓存语句
# 对于SQL语句数量少且内存充足的情况,可设置缓存语句并增大语句的缓存数量
# 此处的数量指预执行语句,通常指动态拼接语句(语句相同,参数不同,视为同一语句)
# 是否缓存SQL语句,连接关闭后对应的缓存语句被销毁
pool-prepared-statements: true
# 单个连接的缓存语句的最大数量
max-pool-prepared-statement-per-connection-size: 100
# 网络过滤
web-stat-filter:
# 启用 WebStatFilter
enabled: false
# 拦截所有请求
url-pattern: /*
# 排除静态资源和监控页面
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*,/druid,/druid/"
# 开启会话统计
session-stat-enable: true
# 最大会话统计数量
session-stat-max-count: 1000
# 用户名存储在 Session 中的属性名
principal-session-name: user
# 用户名存储在 Cookie 中的属性名
principal-cookie-name: login_user
# 监控配置(Web 监控台)
stat-view-servlet:
# 是否启用监控台
enabled: false
# 是否可以重置监控台统计信息
reset-enable: true
# 允许的IP地址,不设置则允许所有IP
# allow: 127.0.0.1
# 不允许的IP地址,不设置则允许所有IP
# deny: 192.168.1.100,192.168.2.0/24
# 访问路径
url-pattern: /druid/*
# 登录用户名称
login-username: ssydx
# 登录用户密码
login-password: 123456
# 如果自定义数据源, 过滤器必须显式添加到数据源中
filter:
# SQL监控过滤器
stat:
# 启用SQL监控
enabled: true
# 记录慢SQL
log-slow-sql: true
# 慢SQL的标准
slow-sql-millis: 1000
# 合并相似SQL的统计信息
merge-sql: true
# 防火墙过滤器
wall:
config:
# 允许多语句执行
multi-statement-allow: true
# 日志过滤器
slf4j:
# 开启日志
enabled: true
statement-executable-sql-log-enable: true
logging:
level:
druid:
sql:
# 注意此处要同步开启 DEBUG, 否则即使 Druid 开启, 非 INFO, WARN, ERROR 级也不会被日志框架记录
# Druid 常规日志时 DEBUG 级别
Statement: DEBUG
配置类
package xyz.ssydx.springbootwithdruid.config;
import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.support.http.StatViewFilter;
import com.alibaba.druid.support.http.StatViewServlet;
import com.alibaba.druid.support.http.WebStatFilter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class DruidConfig {
// 存在问题, 配置文件属性不能完全注入
// 如果想自定义数据源, 需要自行创建属性类进行映射, 方便设置数据源属性
// @Bean
// @ConfigurationProperties("spring.datasource.druid")
// public DataSource druidDataSource() {
// return new DruidDataSource();
// }
// 不要同时在配置文件启用 stat-view-servlet 和 在此处配置 StatViewServlet Bean, 可能会造成冲突, 尤其是两者匹配路径不同时
// 测试时发现路径相同时优先使用了此配置类, 但 Tomcat 不允许存在两个映射路径相同的 Servlet
// 推测相同路径的 ServletBean 也许只注册一次?而自定义配置类的注册顺序早于自动配置类, 暂时只能如此解释
// 因为 Druid 并未在自动配置 ServletRegistrationBean 前判断是否已经存在存在该 Bean, 实际也不能直接判断
// ServletRegistrationBean 并不是 Druid 的组件, 而是 Spring 通用的用于把 Servlet 注册为 Bean 的组件
@Bean
public ServletRegistrationBean druidServlet() {
ServletRegistrationBean bean = new ServletRegistrationBean(new StatViewServlet(), "/druid/*");
bean.addInitParameter("loginUsername", "admin");
bean.addInitParameter("loginPassword", "123456");
return bean;
}
// 同样最好不要两处都配置
@Bean
public FilterRegistrationBean druidFilter() {
FilterRegistrationBean bean = new FilterRegistrationBean(new WebStatFilter());
bean.addUrlPatterns("/*");
bean.addInitParameter("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*,/druid,/druid/");
return bean;
}
// spring.datasource.druid.filter.* 下的设置有所不同, 此处暂不展开
}
过滤器简介(扩展知识)
Filter 名称 | Java 类 | 功能简介 |
---|---|---|
StatFilter |
com.alibaba.druid.filter.stat.StatFilter |
SQL 性能监控,统计执行次数、耗时等 |
WallFilter + WallConfig |
com.alibaba.druid.wall.WallFilter , com.alibaba.druid.wall.WallConfig |
SQL 防火墙,防止 SQL 注入攻击 |
ConfigFilter |
com.alibaba.druid.filter.config.ConfigFilter |
支持加密配置(如加密数据库密码) |
EncodingConvertFilter |
com.alibaba.druid.filter.encoding.EncodingConvertFilter |
对结果集进行编码转换(处理乱码) |
Slf4jLogFilter |
com.alibaba.druid.filter.logging.Slf4jLogFilter |
使用 SLF4J 记录 SQL 日志 |
Log4jFilter |
com.alibaba.druid.filter.logging.Log4jFilter |
使用 Log4j 记录 SQL 日志 |
Log4j2Filter |
com.alibaba.druid.filter.logging.Log4j2Filter |
使用 Log4j2 记录 SQL 日志 |
CommonsLogFilter |
com.alibaba.druid.filter.logging.CommonsLogFilter |
使用 Apache Commons Logging 记录 SQL 日志 |
使用日志过滤器的前提是引入对应的日志框架
Security
安全的核心是认证和授权
简述
安全是一个非常复杂的问题,远不是看上去的认证和授权这么简单
这里面涉及 数据库设计(角色权限),会话持久化,cookie 和 session,JWT(无状态会话),MFA(多因素认证),双Token机制,Oauth2(三方认证),CRSF(跨站请求伪造),固定会话攻击,XSS(跨站脚本攻击)等相关问题
Spring Security
Spring Security 相比 Shiro,和 Spring 的结合更加紧密,底层更加复杂,但功能也更加强大
示例
依赖
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring5</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置
server:
port: 8099
spring:
thymeleaf:
cache: false
debug: true
SecurityConfig
package xyz.ssydx.springbootwithsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
// 开启安全管理, 基于过滤器
@EnableWebSecurity
// 开启方法注解, 基于 AOP
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// 自动加密器,可以根据数据库中的密码前缀 自动选择加密器 对表单输入的密码加密 然后比较
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// 内存型
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.builder().username("zs").password(passwordEncoder().encode("123456")).roles("A").build());
manager.createUser(User.builder().username("ls").password("{noop}123456").roles("B").build());
manager.createUser(User.withUsername("ssydx").password(passwordEncoder().encode("123456")).roles("C").build());
return manager;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(auth -> {
auth
.mvcMatchers("/index","/").permitAll()
.mvcMatchers("/A/**").hasRole("A")
.mvcMatchers("/B/**").hasRole("B")
.mvcMatchers("/C/**").authenticated()
.anyRequest().permitAll()
;
});
// 登录
http.formLogin(login -> {
login
.loginPage("/toLogin")
.usernameParameter("name")
.passwordParameter("pwd")
.loginProcessingUrl("/virtual_login")
.defaultSuccessUrl("/")
.permitAll()
;
});
// 记住我
http.rememberMe(checkbox -> {
checkbox
// 必须设置
.userDetailsService(userDetailsService())
.rememberMeParameter("remember")
.tokenValiditySeconds(3600 * 24)
;
});
// 注销
http.logout(logout -> {
logout
.logoutUrl("/virtual_logout")
.logoutSuccessUrl("/")
.deleteCookies("JSESSIONID")
.permitAll();
;
});
// csrf 防护
// http.csrf(csrf -> {
// csrf.disable();
// });
return http.build();
};
}
MyController
package xyz.ssydx.springbootwithsecurity.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
@Controller
public class MyController {
@RequestMapping({"/","/index"})
public String index(Model model){
return "index";
}
@GetMapping("toLogin")
public String toLogin(HttpServletRequest request, Model model) {
Exception exception = (Exception) request.getSession().getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
if (exception != null) {
model.addAttribute("loginerror", true);
model.addAttribute("message", exception.getMessage());
}
return "toLogin";
}
@GetMapping("toLogout")
public String toLogout() {
return "toLogout";
}
@RequestMapping("/A/{id}")
public String A(@PathVariable("id") int id) {
return "A/" + id;
}
@RequestMapping("/B/{id}")
public String B(@PathVariable("id") int id) {
return "B/" + id;
}
@RequestMapping("/C/{id}")
public String C(@PathVariable("id") int id) {
return "C/" + id;
}
@PreAuthorize("isAuthenticated()")
@RequestMapping("/D")
public String D() {
return "D";
}
}
index.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>主页</title>
<!-- 引入 Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="#">我的系统</a>
<div class="ms-auto">
<a th:href="@{/toLogin}" sec:authorize="!isAuthenticated()" class="btn btn-outline-light btn-sm me-2">登录</a>
<span sec:authorize="isAuthenticated()" class="btn btn-outline-light btn-sm">欢迎你,<span sec:authentication="name"></span>!</span>
<a th:href="@{/toLogout}" sec:authorize="isAuthenticated()" class="btn btn-outline-light btn-sm">注销</a>
</div>
</div>
</nav>
<!-- 页面主体 -->
<div class="container">
<!-- 权限 A -->
<div sec:authorize="hasRole('A')" class="card mb-4">
<div class="card-header bg-info text-white">
权限 A 的链接
</div>
<div class="card-body">
<a th:href="@{A/1}" class="btn btn-outline-info me-2">A1</a>
<a th:href="@{A/2}" class="btn btn-outline-info">A2</a>
</div>
</div>
<!-- 权限 B -->
<div sec:authorize="hasRole('B')" class="card mb-4">
<div class="card-header bg-success text-white">
权限 B 的链接
</div>
<div class="card-body">
<a th:href="@{B/1}" class="btn btn-outline-success me-2">B1</a>
<a th:href="@{B/2}" class="btn btn-outline-success">B2</a>
</div>
</div>
<!-- 所有已登录用户可见 -->
<div sec:authorize="isAuthenticated()" class="card mb-4">
<div class="card-header bg-secondary text-white">
所有登录用户可见
</div>
<div class="card-body">
<a th:href="@{C/1}" class="btn btn-outline-secondary me-2">C1</a>
<a th:href="@{C/2}" class="btn btn-outline-secondary">C2</a>
</div>
</div>
<!-- 全站可见 -->
<div class="card">
<div class="card-body">
<a th:href="@{/D}" class="btn btn-link">D(所有人都可访问)</a>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
toLogin.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户登录</title>
<!-- 引入 Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm w-100" style="max-width: 400px;">
<div class="card-body">
<h4 class="card-title text-center mb-4">用户登录</h4>
<!-- 错误提示 -->
<div th:if="${message}" class="alert alert-danger" role="alert">
<strong>错误:</strong> <span th:text="${message}"></span>
</div>
<!-- 登录表单 -->
<form th:action="@{/virtual_login}" method="post">
<div class="mb-3">
<label for="name" class="form-label">用户名</label>
<input type="text" class="form-control" id="name" name="name" placeholder="请输入用户名" required>
</div>
<div class="mb-3">
<label for="pwd" class="form-label">密码</label>
<input type="password" class="form-control" id="pwd" name="pwd" placeholder="请输入密码" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">记住我</label>
</div>
<button type="submit" class="btn btn-primary w-100">登录</button>
</form>
</div>
</div>
</div>
<!-- 引入 Bootstrap 5 JS(可选) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
toLogout.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注销确认</title>
<!-- 引入 Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container d-flex align-items-center justify-content-center min-vh-100">
<div class="card border-danger shadow-sm w-100" style="max-width: 400px;">
<div class="card-header bg-danger text-white text-center">
<h5 class="mb-0">确认注销</h5>
</div>
<div class="card-body text-center">
<p class="mb-4">你确定要退出当前账户吗?</p>
<!-- 注销表单 -->
<form action="/virtual_logout" method="post">
<input type="hidden" name="_csrf" th:value="${_csrf.token}" />
<button type="submit" class="btn btn-danger w-100">确认注销</button>
</form>
<!-- 取消链接 -->
<a href="/" class="btn btn-outline-secondary mt-3 w-100">取消</a>
</div>
</div>
</div>
<!-- 引入 Bootstrap 5 JS(可选) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
Shiro
Shiro 使用简单,甚至可以脱离 Web 环境,但不如 Spring Security 和 Spring 的结合
三大对象
Subject:用户上下文(安全上下文)
SecurityManager:所有用户的管理者(一般不直接操作)
Realm:鉴权和授权的核心
示例
依赖
<dependency>
<groupId>com.github.theborakompanioni</groupId>
<artifactId>thymeleaf-extras-shiro</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.1</version>
</dependency>
配置
server:
port: 8099
spring:
thymeleaf:
cache: false
debug: true
MyPrincipal
package xyz.ssydx.springbootwithshiro.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.io.Serializable;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
// 实现序列化接口, 否则记住我功能无法正确序列化
public class MyPrincipal implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private String roles;
private String permissions;
}
ShiroConfig
package xyz.ssydx.springbootwithshiro.config;
import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.mgt.RememberMeManager;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.session.Session;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.LinkedHashMap;
@Configuration
public class ShiroConfig {
// Subject 和 SpringSecurity 中的 SecurityContext 作用几乎相同
// 都是根据请求中的 cookie(也可能是 JWT 等 token) 获得用户的状态(角色、权限等), 伴随请求(准确而言是请求所在的线程)存在
// 用户处理(含认证和授权), 核心配置其一
@Bean
public AuthorizingRealm userRealm() {
// 实现 AuthorizingRealm 并创建一个 Realm
AuthorizingRealm userRealm = new AuthorizingRealm() {
// 认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
// 实际开发 admin 换为数据库查询是否存在指定用户
if (!token.getUsername().equals("admin")) {
throw new UnknownAccountException("用户名不存在");
}
// 封装用户凭证(不要包含密码)
MyPrincipal userInfo = new MyPrincipal();
userInfo.setUsername(token.getUsername());
userInfo.setRoles("user");
userInfo.setPermissions("user:del");
// 将用户凭证放入会话(本质和HttpSession是相同的, 两者共享数据, 也就是说两者存取的是同一份数据)
Subject user = SecurityUtils.getSubject();
Session session = user.getSession();
session.setAttribute("userInfo", userInfo);
// 实际开发 123456 换为数据库查询出的用户的密码
return new SimpleAuthenticationInfo(userInfo, "123456", getName());
}
// 授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
MyPrincipal userInfo = (MyPrincipal) principalCollection.getPrimaryPrincipal();
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addStringPermission(userInfo.getPermissions());
return authorizationInfo;
}
};
return userRealm;
}
// 要启用记住我功能, 需要注册 RememberMeManager
@Bean
public RememberMeManager rememberMeManager() {
CookieRememberMeManager rememberMeManager = new CookieRememberMeManager();
// 可以自定义 cookie 名称、过期时间等
SimpleCookie cookie = new SimpleCookie("rememberMe");
cookie.setHttpOnly(true);
cookie.setMaxAge(3600 * 24); // 24小时
rememberMeManager.setCookie(cookie);
return rememberMeManager;
}
// 用户管理, 通常不需要直接操作, 借助 SecurityUtils 获取用户(Subject)
@Bean
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm(userRealm());
manager.setRememberMeManager(rememberMeManager());
return manager;
}
// 过滤设置, 核心配置其二
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager());
bean.setLoginUrl("/toLogin");
bean.setUnauthorizedUrl("/unauthorized");
// 使用有序映射保证顺序
LinkedHashMap<String, String> filterLinkedHashMap = new LinkedHashMap<>();
// 认证成功可访问
filterLinkedHashMap.put("/user/add", "authc");
// 具备指定权限可访问
filterLinkedHashMap.put("/user/del", "perms[user:del]");
bean.setFilterChainDefinitionMap(filterLinkedHashMap);
return bean;
}
// 设置 shiro 方言, 以便整合 thymeleaf
@Bean
public ShiroDialect shiroDialect() {
ShiroDialect shiroDialect = new ShiroDialect();
return shiroDialect;
}
}
MyController
package xyz.ssydx.springbootwithshiro.controller;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import xyz.ssydx.springbootwithshiro.config.MyPrincipal;
import javax.servlet.http.HttpSession;
@Controller
public class MyController {
@RequestMapping({"/","/index"})
public String index() {
Session session = SecurityUtils.getSubject().getSession();
// 此处仅用于测试 HttpSession 和 ShiroSession 共享数据
if (session.getAttribute("userInfo") != null) {
MyPrincipal userInfo = (MyPrincipal) session.getAttribute("userInfo");
System.out.println(userInfo.getUsername());
}
return "index";
}
@RequestMapping("/toLogin")
public String toLogin(Model model, HttpSession session) {
model.addAttribute("error", session.getAttribute("error"));
return "toLogin";
}
@PostMapping("/doLogin")
public String doLogin(
@RequestParam("username") String username,
@RequestParam("password") String password,
// 需要注意的是: 如果复选框未勾选, 参数压根不会传递, 因此此处设置 required = false, defaultValue = "false"
// 允许没有 remember 参数, 且没有参数时默认使用 false(没勾选自然就是 false)
@RequestParam(name = "remember", required = false, defaultValue = "false") boolean remember,
Model model, HttpSession session) {
// 把 表单信息封装到 Token, 便于 Realm 进行认证
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(username, password, remember);
try {
// 执行该方法后会转交给 Realm 进行认证
subject.login(token);
return "redirect:index";
} catch (AuthenticationException e) {
System.out.println(e.getMessage());
session.setAttribute("error", e.getMessage());
return "redirect:toLogin";
}
}
@RequestMapping("/toLogout")
public String toLogout() {
return "toLogout";
}
@PostMapping("/doLogout")
public String doLogout() {
// 登出
Subject subject = SecurityUtils.getSubject();
subject.logout();
return "redirect:index";
}
// 定制一个缺少权限时显示的页面
@RequestMapping("/unauthorized")
public String unauthorized() {
return "error/401";
}
@RequestMapping("/user/add")
public String add() {
return "user/add";
}
@RequestMapping("/user/del")
public String del() {
return "user/del";
}
}
index.html
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:shiro="http://www.thymeleaf.org/thymeleaf-extras-shiro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<h1>首页</h1>
<!--
此处不能使用 session 判断, 因为 session 是和 jsessionid 关联的
重启浏览器后, 旧的 jsessionid 已经丢失, 新的 jsessionid 并不能直接关联到 之前的 session 上
shiro 只会根据 rememberMe(shiro 的 cookie 的值是加密后的用户信息 ) 重建 Subject
这点和 SpringSecurity 不同, SpringSecurity 会自动重建 session(借助 RememberMeAuthenticationFilter 过滤器)
-->
<!-- <div th:if="${session.userInfo != null}">-->
<!-- <p th:text="${session.userInfo.username}"></p>-->
<!-- </div>-->
<div shiro:user="" >
<p shiro:principal property="username"></p>
</div>
<a shiro:guest="" th:href="@{/toLogin}">登录</a>
<a shiro:user="" th:href="@{/toLogout}">注销</a>
<hr>
<a shiro:user="" th:href="@{/user/add}">add</a>
<hr>
<a shiro:hasPermission="user:del" th:href="@{/user/del}">del</a>
<hr>
<!-- 本例中永远不会显示, 因为用户没有此权限 -->
<a shiro:hasPermission="user:other" th:href="@{/user/other}">other</a>
</body>
</html>
toLogin.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<p th:if="${error != null}" th:text="${error}"></p>
<form th:action="@{/doLogin}" method="post">
<input type="text" name="username">
<input type="password" name="password">
<input type="checkbox" name="remember">
<input type="submit" value="提交">
</form>
</body>
</html>
toLogout.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<form th:action="@{/doLogout}" method="post">
<p>你确定登出?</p>
<input type="submit" value="提交">
</form>
</body>
</html>
权限类型
值 | 含义 |
---|---|
anon |
匿名访问,不需要登录 |
authc |
必须登录才能访问(认证) |
logout |
注销功能 |
authcBasic |
HTTP Basic 认证 |
user |
用户已登录或记住我都可以访问(适合 rememberMe 场景) |
roles[admin] |
必须具有 admin 角色才能访问 |
roles[admin,user] |
必须同时具有 admin 和 user 角色(注意:实际中不推荐这么写,Shiro 不支持多角色 AND 判断) |
perms["user:del"] |
必须拥有 user:del 权限才能访问(基于权限字符串的判断) |
perms[user:add, user:edit] |
多个权限,用户只要拥有其中一个即可?❌ 错误!Shiro 默认 OR 是不生效的,需自定义逻辑 |
port[80] |
只有通过指定端口访问才允许 |
ssl |
必须启用 SSL 才能访问 |
noSessionCreation |
不创建 session(适用于无状态 REST API) |
本专栏包含Java、MySQL、JavaWeb、Spring、Redis、Docker等等,作为个人学习记录及知识总结,将长期进行更新! 有相关问题或错误指正欢迎交流沟通,邮箱ssydx@qq.com