SpringBoot
创建简单视图
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
// 适用于前后不分离项目,创建简单的视图控制器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// 将 url / 映射到 /home
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
}
}Spring 注入方式
建议始终在 bean 中使用基于构造函数的依赖注入,并对强制依赖使用断言校验。 构造器注入能保证依赖在对象创建时就已就绪、不为 null,还便于把字段声明为 final(不可变);相比之下字段注入无法用 final,也更难写单元测试。
// 基于 Lombok 提供的注解注入
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserDao userDao;
}
// 基于构造函数注入
public class UserServiceImpl implements UserService {
private final UserDao userDao;
@Autowired
public UserServiceImpl(UserDao userDao) {
Assert.notNull(userDao, "userDao must not be null");
this.userDao = userDao;
}
}
// 基于 Setter 注入
public class UserServiceImpl implements UserService {
// setter 注入的字段不能声明为 final(final 字段必须在构造时初始化)
private UserDao userDao;
// Spring 4.3 及以后的版本中,@Autowired 可不写
@Autowired
public void setUserDao(UserDao userDao) {
Assert.notNull(userDao, "userDao must not be null");
this.userDao = userDao;
}
}
// 基于字段注入
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao;
}属性配置
使用@ConfigurationProperties 注解示例
# 配置示例
alipay:
appId: 666
aliAppPublicKey: 555@Data
@Component
@ConfigurationProperties(prefix="alipay")
public class AlipayConfig {
private String appId;
private String aliAppPublicKey;
}提示
在 IDE 中可能看到 yml 文件存在“unknown property”警告,这是因为 IDE 会尝试寻找它所需要的元数据,以便了解这些属性的含义; 在 pom.xml 中添加如下依赖即可消除警告
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>应用启动时执行
Spring Boot 提供了两个非常有用的接口,用于在应用启动的时候执行一定的逻辑,即 CommandLineRunner 和 ApplicationRunner
因为 CommandLineRunner 和 ApplicationRunner 都是函数式接口,都需要实现一个 run() 方法,所以在配置类中可以很容易地将其声明为 bean,只需在一个返回 lambda 表达式的方法上使用@Bean 注解
CommandLineRunner 和 ApplicationRunner 的区别在于各自 run() 方法的参数; CommandLineRunner 的 run() 方法接受一个 String 类型的可变长度参数; ApplicationRunner 的 run() 方法接受一个 ApplicationArguments 参数;
@Bean
public CommandLineRunner dataLoader(MyService service) {
return args -> {
// args 是原始命令行参数数组(未做任何解析)
// 如 java -jar xxx.jar --version 1.0 运行时,args 为 ["--version", "1.0"]
// CommandLineRunner 拿到的就是这样原样、未拆分选项的参数
service.doSomething();
};
}@Bean
public ApplicationRunner dataLoader(MyService service) {
return args -> {
// 相对于 CommandLineRunner,ApplicationRunner 获取命令行参数较简洁,无需转换
List<String> version = args.getOptionValues("version");
service.doSomething();
};
}加载外部 jar 包
在项目 resources 目录下创建一个文件夹,例如名称为 lib ,将需要加载的 jar 包放入新建的 lib 目录中,并修改 pom.xml,配置如下:
<!-- 外部引入的 jar 包,g、a、v 可随意定义 -->
<dependency>
<groupId>com.zjx</groupId>
<artifactId>zjx-test-a</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/a.jar</systemPath>
</dependency>
<dependency>
<groupId>com.zjx</groupId>
<artifactId>zjx-test-b</artifactId>
<version>1.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/b.jar</systemPath>
</dependency>提示
使用 systemPath 这种方式引入外部 jar 包,其实是不推荐的写法,在不同的开发机、CI/CD 环境、Docker 容器中,相对路径很可能不一致,导致构建失败;而且,与 Maven 的依赖传递也可能造成冲突。
更推荐的方式是将 jar 包上传到仓库中,通过普通依赖引入:
<dependency>
<groupId>com.zjx</groupId>
<artifactId>zjx-test-a</artifactId>
<version>1.0</version>
</dependency># 适合个人开发或临时测试
mvn install:install-file \
-Dfile=path/to/a.jar \
-DgroupId=com.zjx \
-DartifactId=zjx-test-a \
-Dversion=1.0 \
-Dpackaging=jar# 适合团队使用
mvn deploy:deploy-file \
-Dfile=path/to/a.jar \
-DgroupId=com.zjx \
-DartifactId=zjx-test-a \
-Dversion=1.0 \
-Dpackaging=jar \
-Durl=http://your-nexus/repository/maven-releases/ \
-DrepositoryId=your-repo-id日志配置
SLF4J 是日志的接口标准,Logback 和 Log4j2 都是 SLF4J 的具体实现。Spring Boot 2.x 与 3.x 默认都使用 Logback,如需换成 Log4j2,排除 spring-boot-starter-logging 再引入 spring-boot-starter-log4j2 即可。
日志的常规配置直接用 Spring Boot 的 logging.* 属性写在 application.yml 里即可,简单场景无需额外 XML 文件。下面这份 application.yml 同时供后面的 logback-spring.xml 读取:
spring:
application:
# 应用名(日志的 service 字段、ELK 索引名都用它)
name: my-app
profiles:
active: dev
logging:
# 各 logger 的级别:logging.level.<包名/类名>
level:
root: INFO
org.springframework.security: DEBUG
# 输出到文件:logging.file.name 是「路径 + 文件名」(老写法 logging.file / logging.path 已废弃)
file:
name: logs/app.log
# Spring Boot 3.2+:控制台 / 文件的输出阈值
threshold:
console: INFO
file: WARN
# logstash 地址(供 logback-spring.xml 的 <springProperty> 读取)
logstash:
destination: 127.0.0.1:5044注意区分:
logging.level.*控制的是「哪个 logger 输出什么级别」,logging.threshold.*控制的是「某个 appender(控制台/文件)放行什么级别」,两者是不同维度。
需要完全掌控日志(多 appender、滚动策略、自定义格式等)时,再到类路径根目录(src/main/resources)下创建 logback-spring.xml。它通过 <springProperty> 读取上面 yml 里的 spring.application.name、spring.profiles.active、logging.threshold.console、logstash.destination;各 logger 与 root 的级别直接写在 XML 中。
文件名必须是 logback-spring.xml
示例中用到了 <springProperty>、<springProfile> 这类 Spring Boot 扩展标签,文件必须命名为 logback-spring.xml,不能用 logback.xml——因为原生 logback.xml 会在 Spring 启动【之前】被 logback 加载,那时 Spring 的 Environment 尚未就绪,扩展标签会失效甚至报错。
<?xml version="1.0" encoding="UTF-8"?>
<!-- debug="true" 会打印 logback 自身的加载过程,排查配置不生效时很有用 -->
<configuration debug="false">
<!--默认配置-->
<!--<include resource="org/springframework/boot/logging/logback/defaults.xml"/>-->
<!--配置控制台 (Console)-->
<!--<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>-->
<!-- springProperty:把 Spring 的 Environment(application.yml/properties)中的属性注入为 logback 变量
source = Spring 侧的属性键;name = 注入后的 logback 变量名(用 ${name} 引用);
defaultValue = source 取不到值时的兜底,强烈建议都加上,避免解析成空字符串 -->
<springProperty scope="context" name="applicationName" source="spring.application.name" defaultValue="app"/>
<springProperty scope="context" name="env" source="spring.profiles.active" defaultValue="default"/>
<!-- 控制台阈值,取自 logging.threshold.console -->
<springProperty scope="context" name="consoleThreshold" source="logging.threshold.console" defaultValue="INFO"/>
<!-- logstash 地址,改 application.yml 的 logstash.destination 即可 -->
<springProperty scope="context" name="logstash_destination" source="logstash.destination" defaultValue="127.0.0.1:5044"/>
<!--输出到控制台-->
<!-- ConsoleAppender:把日志打印到控制台(标准输出),开发调试时最常用 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!-- ThresholdFilter:只放行 >= 指定级别的日志 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${consoleThreshold}</level>
</filter>
<encoder>
<pattern>[%date{yyyy-MM-dd HH:mm:ss}] [ %-5level] [%X{traceId}] [%X{requestId}] %logger{96} [%line] - %msg%n</pattern>
<!-- 设置字符集 -->
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- RollingFileAppender:把日志写入文件,并按策略“滚动”(切割)成多个文件,避免单个日志文件无限膨胀 -->
<appender name="DEBUG_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/debug/application.log</file>
<!-- rollingPolicy:定义何时、如何滚动文件;
SizeAndTimeBasedRollingPolicy 按“时间 + 大小”双维度滚动——先按天切分,单个文件再超过 maxFileSize 时继续拆分 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/debug/application-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>5MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>[%date{yyyy-MM-dd HH:mm:ss}] [ %-5level] [%X{traceId}] [%X{requestId}] %logger{96} [%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- LevelFilter:精确只收某一个级别(onMatch=ACCEPT 命中放行、onMismatch=DENY 其余全拒),
这样每个文件只存自己那一级、互不掺杂;若想「≥ 某级别」则用 ThresholdFilter(如上面的 CONSOLE) -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="INFO_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/info/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/info/application-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>5MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>[%date{yyyy-MM-dd HH:mm:ss}] [ %-5level] [%X{traceId}] [%X{requestId}] %logger{96} [%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 只收 INFO(用法同上 DEBUG_LOG) -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="ERROR_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/error/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/error/application-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>5MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>[%date{yyyy-MM-dd HH:mm:ss}] [ %-5level] [%X{traceId}] [%X{requestId}] %logger{96} [%line] - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 只收 ERROR(用法同上 DEBUG_LOG) -->
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- 为 logstash 输出的 JSON 格式的 Appender -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<!-- 端口需与 logstash 的 tcp input 端口一致(对接 ELK 见下方链接) -->
<destination>${logstash_destination}</destination>
<!-- 日志输出编码 -->
<encoder charset="UTF-8" class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<timeZone>UTC</timeZone>
</timestamp>
<pattern>
<pattern>
{
"severity": "%level",
"service": "${applicationName:-}",
"trace": "%X{traceId:-}",
"pid": "${PID:-}",
"thread": "%thread",
"class": "%logger{40}",
"message": "%message",
"env": "${env}",
"stack_trace": "%exception"
}
</pattern>
</pattern>
</providers>
</encoder>
</appender>
<!-- AsyncAppender:异步写日志。把耗时的文件/网络写入丢到独立线程 + 队列,避免阻塞业务线程。
这里演示给 INFO_LOG 包一层(其余文件 appender 可同理各包一个);
注意 LOGSTASH 自身已是异步(内部环形缓冲),不用再包 -->
<appender name="ASYNC_INFO" class="ch.qos.logback.classic.AsyncAppender">
<!-- 队列长度 -->
<queueSize>1024</queueSize>
<!-- 队列剩余容量 < 该值时丢弃 TRACE/DEBUG/INFO(保留 WARN/ERROR);设 0 表示满了也不丢 -->
<discardingThreshold>0</discardingThreshold>
<appender-ref ref="INFO_LOG"/>
</appender>
<!-- logger:为指定包/类单独设置日志级别与输出目的地
name:生效的包名或类全限定名(这里是 mapper 包,常用于单独打开 MyBatis 的 SQL 日志)
level:该范围的日志级别,DEBUG 会输出该包下 debug 及以上级别的日志
appender-ref:引用前面定义好的 appender,指定日志输出到哪 -->
<!-- additivity="false":本 logger 的日志【只】走自己的 appender(DEBUG_LOG、ASYNC_INFO),不再上抛给 root。
若去掉它(默认 true):这条日志先经本 logger 写一次,再上抛到 root 又写一次
(root 也引用了 ASYNC_INFO)→ info 文件里每条 INFO 日志都会重复打印两遍。 -->
<!-- 注意 appender 也会按各自 filter 再过滤一次:mapper 设成 DEBUG 是为了看 SQL,
就得引一个放行 DEBUG 的 appender(这里的 DEBUG_LOG);INFO 及以上则落到 ASYNC_INFO。
若改引 CONSOLE(阈值 INFO)或 INFO_LOG,DEBUG 会被过滤掉、白设。 -->
<logger name="com.zjx.com.mapper" level="DEBUG" additivity="false">
<appender-ref ref="DEBUG_LOG"/>
<appender-ref ref="ASYNC_INFO"/>
</logger>
<!-- logger 可以配置多个。这条只设 level、不配 appender:把过于啰嗦的第三方库压到 WARN,
日志输出仍沿用 root 的 appender(未显式配 appender,additivity 默认 true 会上抛给 root) -->
<logger name="org.apache.kafka" level="WARN"/>
<!-- root:根 logger,全局唯一、只能配一个,是所有 logger 的最终兜底
level 为全局默认级别,其下挂载日志最终要输出到的 appender -->
<root level="INFO">
<!-- springProfile:按 spring.profiles.active 生效,实现「按环境区分 appender」 -->
<springProfile name="dev">
<!-- 开发环境:打到控制台,方便本地看 -->
<appender-ref ref="CONSOLE"/>
</springProfile>
<springProfile name="prod">
<!-- 生产环境:发往 ELK,不再刷控制台 -->
<appender-ref ref="LOGSTASH"/>
</springProfile>
<!-- 文件日志各环境都保留(INFO 走异步的 ASYNC_INFO) -->
<appender-ref ref="ASYNC_INFO"/>
<appender-ref ref="ERROR_LOG"/>
<!--<appender-ref ref="DEBUG_LOG"/>-->
</root>
</configuration>logger 与 root 的关系
<root>全局唯一,是所有 logger 的兜底;<logger>可配多个,是针对某包 / 类的「例外规则」(两种用法见上面mapper、kafka两例)。- 级别就近继承:logger 未显式设
level时,向上就近继承最近祖先的级别,一路到<root>。
配置中还包含三项生产常用加固:按环境区分(springProfile)、异步写(AsyncAppender)、级别精确分文件(LevelFilter),细节见对应注释。
接入 ELK 后,文件日志还要不要?
要,但角色变了——查询 / 分析以 ELK(Kibana)为主,文件退居「本地兜底」:
- LOGSTASH(→ ELK):生产主力,集中检索、跨服务聚合、告警都靠它;端口需与 logstash 的
tcp input一致(完整链路见 Elastic Stack · 整合 SpringBoot)。 - 文件(RollingFile):ELK 或网络故障时的持久兜底(TCP appender 连不上可能丢日志,文件落在本机不丢),以及临时上机
tail排查、合规留存。可只保留ERROR文件,INFO / DEBUG 全量文件按需。 - CONSOLE(stdout):本地开发必备;容器 / K8s 环境更常见的是只打 stdout,由平台或 Filebeat 采集转发,此时应用可不直连 logstash(去掉
LOGSTASHappender)。
一句话选型:本地开发 → CONSOLE;传统部署 + ELK → LOGSTASH 为主 + ERROR 文件兜底;容器化 → 打 stdout 交给平台采集。
Spring Boot 中加载 xml 配置
Spring Boot 推崇 Java + 注解的方式装配 Bean,一般用不到 XML。但在迁移老项目、或引入只提供 XML 配置的第三方库(如早期的 Dubbo、部分 MyBatis 整合)时,仍需把 XML 里定义的 Bean 导入容器,此时用 @ImportResource。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<!-- 等价于 @Bean,把 UserServiceImpl 注册进容器 -->
<bean id="userService" class="com.zjx.service.impl.UserServiceImpl">
<!-- 通过 property 做 setter 注入 -->
<property name="prefix" value="user_"/>
</bean>
</beans>@Configuration
// 可导入多个;支持 classpath:(类路径)与 file:(文件系统绝对路径)前缀
// classpath:/beans.xml 与 classpath:beans.xml 等价;classpath*: 可跨多个 jar 匹配
@ImportResource({"classpath:beans.xml", "file:/opt/app/conf/extra.xml"})
public class MyConfig {
}// 导入后,XML 里的 Bean 与注解声明的 Bean 进的是同一个容器,可直接注入
@RestController
@RequiredArgsConstructor
public class UserController {
// 直接注入 XML 中 id="userService" 的那个 Bean
private final UserService userService;
}提示
- 新代码优先
@Configuration+@Bean:类型安全、可重构、便于单测;@ImportResource只作为兼容存量 XML 的手段。 @ImportResource导入的是 XML 里的 Bean 定义。若只是想加载外部的 properties / yml 属性(而非 Bean),请用@PropertySource(properties 文件)或spring.config.import,两者用途不要混淆。
缓存
以下都为注解式缓存示例,他们可以同时存在,也可以只使用其中一个,根据项目需求添加即可。
Caffeine
Caffeine 是一个高性能、功能强大的本地缓存库。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>cache:
caffeine:
# 默认最大缓存数为 2000,写入后固定 600s 过期
default-spec: maximumSize=2000,expireAfterWrite=600s@Slf4j
@Configuration
@EnableCaching
public class MultiCacheConfig {
@Value("${cache.caffeine.default-spec:maximumSize=1000,expireAfterWrite=600s}")
private String caffeineDefaultSpec;
// 缓存名称常量(统一在全局引用)
public static final String USER_CACHE="userCache";
public static final String PRODUCT_CACHE="productCache";
public static final String SESSION_CACHE="sessionCache";
/**
* Caffeine 缓存管理器 - 本地内存缓存
* 支持根据 cacheName 设置不同策略,未命中使用默认配置
*/
// @Primary:设置为主要缓存管理器
@Bean("caffeineCacheManager")
@Primary
public CacheManager caffeineCacheManager() {
log.info("初始化 Caffeine Cache Manager...");
// 提前构建默认配置,避免多次解析
Caffeine<Object, Object> defaultBuilder = Caffeine.from(caffeineDefaultSpec);
return new CaffeineCacheManager() {
// @SuppressWarnings 解决 Spring 6 @NonNullApi 警告
@Override
@SuppressWarnings("NullableProblems")
protected Cache<Object, Object> createNativeCaffeineCache(String name) {
return switch (name) {
case USER_CACHE -> Caffeine.newBuilder()
// 最大缓存数为 500
.maximumSize(500)
// 写入后固定时间过期
.expireAfterWrite(60, TimeUnit.SECONDS)
.removalListener((key, value, cause) ->
log.debug("清理缓存 key: {} - {}, 原因为:{}", key, value, cause))
.build();
case PRODUCT_CACHE -> Caffeine.newBuilder()
.maximumSize(1000)
// 最后一次访问后固定时间过期
.expireAfterAccess(300, TimeUnit.SECONDS)
.build();
case SESSION_CACHE -> Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
// 默认配置兜底
default -> defaultBuilder.build();
};
}
};
}
}@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/cache")
public class CacheController {
/**
* 测试 Caffeine 缓存
*/
@Cacheable(
// 缓存名称,和 cacheNames 属性是等效的
value = MultiCacheConfig.USER_CACHE,
key="'userId::'+ #userId",
// 指定使用哪种缓存(到容器中找对应的 Bean)
cacheManager="caffeineCacheManager"
)
@GetMapping("/test/caffeine")
public String testCaffeine(Long userId) {
log.info(" caffeine --- 猜猜我会打印几次:{}", userId);
return "caffeine:" + userId;
}
}Redis
<!-- 这里说明一下,如果项目中已经有 Redisson 作为 Redis 客户端,则此处依赖都可以删掉 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>spring:
data:
# 注意这个 Redis 配置主要影响 RedisTemplate 和 RedisCacheManager。
# 如果项目中使用了Redisson,它会被 redisson.yaml 中的 Redis 配置覆盖,即使删掉此处配置,RedisTemplate 依然能用
# 但是无法实现比如使用 RedisTemplate 操作 0 号数据库,Redisson 操作 1 号库这种垃圾需求
# 建议项目中 RedisTemplate 和 Redisson 二选一,不要混着用,以免配置冲突
redis:
host: 127.0.0.1
port: 6379
database: 1
cache:
redis:
default-ttl: 3600@Slf4j
@Configuration
@EnableCaching
public class MultiCacheConfig {
// 对应 application.yml 中的 cache.redis.default-ttl(单位:秒),缺省 3600
@Value("${cache.redis.default-ttl:3600}")
private long redisDefaultTtl;
// 缓存名称常量(统一在全局引用)
public static final String USER_CACHE="userCache";
public static final String PRODUCT_CACHE="productCache";
public static final String SESSION_CACHE="sessionCache";
/**
* Redis 缓存管理器 - 分布式缓存
* 适用于分布式环境下的数据共享
*/
@Bean("redisCacheManager")
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
log.info("初始化 Redis Cache Manager...");
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
// 设置缓存项的存活时间,每次写入缓存时都会刷新 TTL
.entryTtl(Duration.ofSeconds(redisDefaultTtl))
.disableCachingNullValues()
// 配置序列化器,这里的序列化和容器中的 RedisTemplate 无关;RedisCacheManager 专注于方法级缓存,和 RedisTemplate 的手动操作不同
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put(USER_CACHE, defaultConfig.entryTtl(Duration.ofMinutes(30)));
cacheConfigurations.put(PRODUCT_CACHE, defaultConfig.entryTtl(Duration.ofHours(2)));
cacheConfigurations.put(SESSION_CACHE, defaultConfig.entryTtl(Duration.ofDays(1)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/cache")
public class CacheController {
/**
* 测试 Redis 缓存
* 观察 Redis 中 key 为 userCache::userId:xxx ,不要纠结为什么有 :: 符号,这是 Spring 默认分隔符,用于区分缓存名和 key
*/
@Cacheable(
value = MultiCacheConfig.USER_CACHE,
key="'userId:'+ #userId",
cacheManager="redisCacheManager"
)
@GetMapping("/test/redis")
public String testRedis(Long userId) {
log.info(" redis --- 猜猜我会打印几次:{}", userId);
return "redis:" + userId;
}
}Redission
关于 Redission 的依赖和配置项,请参考此篇文档,保证 Spring 容器中有 RedissonClient 这个 Bean 即可。以下是使用 Redisson 实现注解式缓存的示例。
@Slf4j
@Configuration
@EnableCaching
public class MultiCacheConfig {
@Value("${cache.caffeine.default-spec:maximumSize=1000,expireAfterWrite=600s}")
private String caffeineDefaultSpec;
// 缓存名称常量(统一在全局引用)
public static final String USER_CACHE="userCache";
public static final String PRODUCT_CACHE="productCache";
public static final String SESSION_CACHE="sessionCache";
/**
* Redisson 缓存管理器 - 高级分布式缓存
* 适用于需要 TTL + 最大空闲时间控制的场景
*/
@Bean("redissonCacheManager")
public CacheManager redissonCacheManager(RedissonClient redissonClient) {
log.info("初始化 Redisson Cache Manager...");
Map<String, CacheConfig> config = new HashMap<>();
// CacheConfig(ttl, maxIdleTime),单位均为毫秒
config.put(USER_CACHE, new CacheConfig(
// 30 分钟 TTL
TimeUnit.MINUTES.toMillis(30),
// 15 分钟最大空闲时间
TimeUnit.MINUTES.toMillis(15)
));
config.put(PRODUCT_CACHE, new CacheConfig(
// 2 小时 TTL
TimeUnit.HOURS.toMillis(2),
// 30 分钟最大空闲时间
TimeUnit.MINUTES.toMillis(30)
));
config.put(SESSION_CACHE, new CacheConfig(
// 24 小时 TTL
TimeUnit.HOURS.toMillis(24),
// 1 小时最大空闲时间
TimeUnit.HOURS.toMillis(1)
));
// RedissonSpringCacheManager 使用 RMapCache 实现(支持 TTL + Idle)
return new RedissonSpringCacheManager(redissonClient, config);
}
}@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/cache")
public class CacheController {
/**
* 测试 Redisson 缓存
*/
@Cacheable(
value = MultiCacheConfig.USER_CACHE,
key="'userId:'+ #userId",
cacheManager="redissonCacheManager"
)
@GetMapping("/test/redisson")
public String testRedisson(Long userId) {
log.info(" Redisson --- 猜猜我会打印几次:{}", userId);
return "Redisson:" + userId;
}
}单元测试
单元测试案例参考
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.TimeUnit;
/**
* 单元测试案例
*/
@Slf4j
@ComponentScan(basePackages="com.zjx")
// @SpringBootTest 只能在 springboot 主包下使用
@SpringBootTest
public class DemoUnitTest {
private final IMyTestService service;
@Autowired
public DemoUnitTest(IMyTestService service) {
this.service = service;
}
@Test
public void testTest() {
Assertions.assertNotNull(service);
log.info(service.toString());
}
}import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.TimeUnit;
/**
* 单元测试案例
*/
@Slf4j
@ComponentScan(basePackages="com.zjx")
// @SpringBootTest 只能在 springboot 主包下使用
@SpringBootTest
@DisplayName("单元测试案例")
public class DemoUnitTest {
private final IMyTestService service;
@Autowired
public DemoUnitTest(IMyTestService service) {
this.service = service;
}
@DisplayName("测试 @SpringBootTest @Test @DisplayName 注解")
@Test
public void testTest() {
Assertions.assertNotNull(service);
log.info(service.toString());
}
@Disabled
@DisplayName("测试 @Disabled 注解")
@Test
public void testDisabled() {
log.info("@BeforeAll =====");
}
@Timeout(value = 2L, unit = TimeUnit.SECONDS)
@DisplayName("测试 @Timeout 注解")
@Test
public void testTimeout() throws InterruptedException {
Thread.sleep(3000);
log.info("@Timeout =====");
}
@DisplayName("测试 @RepeatedTest 注解")
@RepeatedTest(3)
public void testRepeatedTest() {
log.info("@RepeatedTest =====");
}
@BeforeAll
public static void testBeforeAll() {
log.info("@BeforeAll =====");
}
@BeforeEach
public void testBeforeEach() {
log.info("@BeforeEach =====");
}
@AfterEach
public void testAfterEach() {
log.info("@AfterEach =====");
}
@AfterAll
public static void testAfterAll() {
log.info("@AfterAll =====");
}
}import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* 断言单元测试案例
* 注意:每对断言里的第二句(assertNotEquals / assertNotSame / assertFalse / assertNotNull)
* 故意写成会失败的形式,仅用于演示对应 API,实际运行会抛出断言错误。
*/
@DisplayName("断言单元测试案例")
public class AssertUnitTest {
@DisplayName("测试 assertEquals 方法")
@Test
public void testAssertEquals() {
Assertions.assertEquals("666", new String("666"));
Assertions.assertNotEquals("666", new String("666"));
}
@DisplayName("测试 assertSame 方法")
@Test
public void testAssertSame() {
Object obj = new Object();
Object obj1 = obj;
Assertions.assertSame(obj, obj1);
Assertions.assertNotSame(obj, obj1);
}
@DisplayName("测试 assertTrue 方法")
@Test
public void testAssertTrue() {
Assertions.assertTrue(true);
Assertions.assertFalse(true);
}
@DisplayName("测试 assertNull 方法")
@Test
public void testAssertNull() {
Assertions.assertNull(null);
Assertions.assertNotNull(null);
}
}import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* 带参数单元测试案例
*/
@Slf4j
@DisplayName("带参数单元测试案例")
public class ParamUnitTest {
@DisplayName("测试 @ValueSource 注解")
@ParameterizedTest
@ValueSource(strings = {"t1", "t2", "t3"})
public void testValueSource(String str) {
log.info(str);
}
@DisplayName("测试 @NullSource 注解")
@ParameterizedTest
@NullSource
public void testNullSource(String str) {
log.info(str);
}
@DisplayName("测试 @EnumSource 注解")
@ParameterizedTest
@EnumSource(MyUserType.class)
public void testEnumSource(MyUserType type) {
log.info(type.getUserType());
}
@DisplayName("测试 @MethodSource 注解")
@ParameterizedTest
@MethodSource("getParam")
public void testMethodSource(String str) {
log.info(str);
}
public static Stream<String> getParam() {
List<String> list = new ArrayList<>();
list.add("t1");
list.add("t2");
list.add("t3");
return list.stream();
}
@BeforeEach
public void testBeforeEach() {
log.info("@BeforeEach =====");
}
@AfterEach
public void testAfterEach() {
log.info("@AfterEach =====");
}
}import org.junit.jupiter.api.*;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 标签单元测试案例
*/
@SpringBootTest
@DisplayName("标签单元测试案例")
public class TagUnitTest {
@Tag("dev")
@DisplayName("测试 @Tag dev")
@Test
public void testTagDev() {
log.info("dev");
}
@Tag("prod")
@DisplayName("测试 @Tag prod")
@Test
public void testTagProd() {
log.info("prod");
}
@Tag("local")
@DisplayName("测试 @Tag local")
@Test
public void testTagLocal() {
log.info("local");
}
@Tag("exclude")
@DisplayName("测试 @Tag exclude")
@Test
public void testTagExclude() {
log.info("exclude");
}
@BeforeEach
public void testBeforeEach() {
log.info("@BeforeEach =====");
}
@AfterEach
public void testAfterEach() {
log.info("@AfterEach =====");
}
}启用 WebSocket
pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>实现方式
方式一(推荐)@Component @ServerEndpoint("/first-endpoint") public class FirstWebSocketEndpoint { @OnOpen public void onOpen(Session session) { // 连接建立时执行 } @OnMessage public void onMessage(String message, Session session) { // 收到消息时执行 } @OnClose public void onClose(Session session) { // 连接关闭时执行 } @OnError public void onError(Session session, Throwable error) { // 发生错误时执行 } }方式一配置类@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }方式二// 启用 Spring WebSocket 支持 @Configuration @EnableWebSocket @RequiredArgsConstructor public class WebSocketConfig implements WebSocketConfigurer { private final FirstWebSocketHandler myFirstHandler; private final SecondWebSocketHandler mySecondHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myFirstHandler, "/first-websocket") .setAllowedOrigins("*"); registry.addHandler(mySecondHandler, "/second-websocket") .setAllowedOrigins("*"); } }方式二 Handler@Slf4j @Component public class FirstWebSocketHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 连接建立时的逻辑 session.sendMessage(new TextMessage("已连接到第一个 WebSocket 服务")); log.info("新连接建立 sessionId={}, url 参数:{}", session.getId(), session.getAttributes().toString()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 处理收到的文本消息 String payload = message.getPayload(); session.sendMessage(new TextMessage("收到你的消息:" + payload)); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 连接关闭时的逻辑 log.info("第一个 WebSocket 连接已关闭:{}", session.getId()); } }
解决跨域问题
使用 @CrossOrigin 注解(不推荐)
在控制器方法或类上添加 @CrossOrigin 注解,适用于小型项目或单个接口的跨域配置
@RestController @RequestMapping("/api") @CrossOrigin(origins="https://example.com", maxAge = 3600) public class MyController { @GetMapping("/resource") public ResponseEntity<?> getResource() { ... } }全局配置(推荐)
通过 WebMvcConfigurer 实现统一配置,适合管理所有端点的 CORS 规则。适用于未接入其他安全框架(如Spring Security)。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 所有接口 registry.addMapping("/**") // 严格指定可信域名,建议生产环境使用 .allowedOrigins("https://trusted-domain.com", "https://another-domain.com") // 通配符指定可信域名,生产环境需注意是否存在安全风险 // .allowedOriginPatterns("https://*.trusted-domain.com", "https://another-domain.com") // 允许的 HTTP 方法 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许所有请求头 .allowedHeaders("*") // 允许携带 Cookie .allowCredentials(true) // 1 小时内不需要预检请求 .maxAge(3600); } }Spring Security 集成配置
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(cors -> cors.configurationSource(corsConfigurationSource())); // ... 其他安全配置(按需继续调用 http.xxx()) return http.build(); } // CORS 配置源 CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList("https://trusted-domain.com")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS")); config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }
参数校验
Spring Boot 参数校验基于 Bean Validation(JSR 380), 其依赖 spring-boot-starter-validation 已被 Spring Boot Starter 内置,建议统一使用 @Validated 实现参数校验。
以下是一份标准的参数校验示例代码,包含分组、嵌套以及常见的各种数据类型校验,非常实用:
分组校验接口
分别创建分组接口,并继承默认 Default 分组;若不继承, 不指定 groups 的校验注解只属于 Default 组(下文提示会介绍)。
import jakarta.validation.groups.Default; public interface AddGroup extends Default {} public interface QueryGroup extends Default {} public interface UpdateGroup extends Default {}校验示例
User@Data public class User implements Serializable { @Serial private static final long serialVersionUID = 1L; @NotNull(message="用户id不能为空", groups = {UpdateGroup.class}) private Long id; // 👉 注意观察 @Size 中,并没有指定 groups 属性,它归属于默认分组(Default),由于我们创建的分组都继承了 Default 分组,所以 @Size 注解也会生效 @NotBlank(message="用户名不能为空", groups = {AddGroup.class, UpdateGroup.class}) @Size(min = 1, max = 10, message="用户名长度必须在1-10之间") private String name; // 👉 age 属于全局必填字段,因此只使用 Default 分组 @NotNull(message="年龄不能为空") @Min(value = 1, message="年龄必须大于1岁") @Max(value = 200, message="年龄必须小于200岁") private Integer age; // 👉 非必传,如果传了,则必须是邮箱格式 @Email(message="邮箱格式不正确") private String email; @NotBlank(message="手机号不能为空") @Pattern(regexp="^1[3456789]\\d{9}$", message="手机号格式不正确") private String phone; @Past(message="生日必须是过去的时间") private LocalDate birthday; // 👉 @Valid 注解必须加上才能实现嵌套对象验证 @Valid @NotNull(message="地址信息不能为空") private Address address; @NotEmpty(message="技能不能为空") @Size(min = 1, max = 5, message="技能数量必须在1-5个之间") private List<String> skills; @Valid @NotEmpty(message="爱好不能为空") private List<Hobby> hobbies; }Address@Data public class Address { private Long id; @NotBlank(message="省份不能为空") private String province; @NotBlank(message="城市不能为空") private String city; }Hobby@Data public class Hobby { private Long id; @NotBlank(message="爱好名称不能为空") private String name; }测试@RestController @RequestMapping("/test-validate") public class TestValidateController { @PostMapping("/add") public User add(@RequestBody @Validated(AddGroup.class) User user) { return user; } @PostMapping("/update") public User update(@RequestBody @Validated(UpdateGroup.class) User user) { return user; } }提示
观察下面例子,猜猜看,如果 AddGroup 没有继承 Default 分组会发生什么?
// 校验字段示例 @NotBlank(message="用户名不能为空", groups = {AddGroup.class}) @Size(min = 1, max = 10, message="用户名长度必须在1-10之间") private String name; // 校验方法示例 @PostMapping("/add") public User add(@RequestBody @Validated(AddGroup.class) User user) { ... }上面示例中,@Size 没有指定 groups 参数,则它只会归属 Default 组,此时 @Validated(AddGroup.class) 只会检测属于 AddGroup 组的校验注解,@Size 根本不会生效!
因此,为了避免混淆,建议每个自定义的分组接口都去继承一下 Default 组,这样不标注分组的校验注解也会全局生效!
定时任务
SpringBoot 定时任务
Spring 内置定时任务,无需引入额外依赖:先在启动类(或任意配置类)上加 @EnableScheduling 开启,再在方法上用 @Scheduled 声明触发规则即可。
// 开启定时任务支持(通常放在启动类上)
@EnableScheduling
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}@Slf4j
@Component
public class MyScheduledTasks {
// fixedRate:从上一次【开始】时刻起,每隔 5 秒再次触发(不等上次执行完)
@Scheduled(fixedRate = 5000)
public void fixedRateTask() {
log.info("fixedRate:每 5 秒执行一次");
}
// fixedDelay:上一次【执行结束】后再隔 5 秒触发(串行,不会重叠)
// initialDelay:项目启动后首次执行的延迟
@Scheduled(fixedDelay = 5000, initialDelay = 2000)
public void fixedDelayTask() {
log.info("fixedDelay:上次结束后隔 5 秒执行,首次延迟 2 秒");
}
// cron:按表达式触发,下面表示每天 0 点整
@Scheduled(cron="0 0 0 * * ?")
public void cronTask() {
log.info("cron:每天零点执行");
}
}提示
@Scheduled默认由单线程串行调度:多个任务或某个耗时任务会互相阻塞、延后。需要并行时,配置ThreadPoolTaskScheduler(或在任务方法上加@Async并配@EnableAsync)。- Spring 的 cron 是 6 位:
秒 分 时 日 月 周,不支持 Quartz 的第 7 位「年」。 fixedRate/fixedDelay的单位是毫秒;若想用字符串配置化,可写fixedRateString="${xxx}"。

