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) ❌ 不支持 ✅ 支持(如 userNameuser-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 必须为 truefalse

多环境配置

单独子配置文件

存在 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] 必须同时具有 adminuser 角色(注意:实际中不推荐这么写,Shiro 不支持多角色 AND 判断)
perms["user:del"] 必须拥有 user:del 权限才能访问(基于权限字符串的判断)
perms[user:add, user:edit] 多个权限,用户只要拥有其中一个即可?❌ 错误!Shiro 默认 OR 是不生效的,需自定义逻辑
port[80] 只有通过指定端口访问才允许
ssl 必须启用 SSL 才能访问
noSessionCreation 不创建 session(适用于无状态 REST API)
#java##spring##spring boot#
计算机编程合集 文章被收录于专栏

本专栏包含Java、MySQL、JavaWeb、Spring、Redis、Docker等等,作为个人学习记录及知识总结,将长期进行更新! 有相关问题或错误指正欢迎交流沟通,邮箱ssydx@qq.com

全部评论
mark
点赞 回复 分享
发布于 今天 11:30 山东

相关推荐

asodh:很久没有刷到过这种尸体暖暖的帖子了,你一定也是很优秀的mentor👍
点赞 评论 收藏
分享
评论
点赞
2
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务