文章目录
尚庭公寓知识点
1、转换器(Converter)
场景:当数据库中存在多个状态字段,且都使用了枚举类进行了封装,此时就需要转换器(p99集)
在讲解之前我们需要知道请求参数从发起到响应的流程:
- 请求流程
说明
-
SpringMVC中的
WebDataBinder
组件负责将HTTP的请求参数绑定到Controller方法的参数,并实现参数类型的转换。 -
Mybatis中的
TypeHandler
用于处理Java中的实体对象与数据库之间的数据类型转换。 -
响应流程
说明
- SpringMVC中的
HTTPMessageConverter
组件负责将Controller方法的返回值(Java对象)转换为HTTP响应体中的JSON字符串,或者将请求体中的JSON字符串转换为Controller方法中的参数(Java对象),例如下一个接口保存或更新标签信息
- SpringMVC中的
WebDataBinder枚举类型转换
WebDataBinder
依赖于Converter
实现类型转换,若Controller方法声明的@RequestParam
参数的类型不是String
,WebDataBinder
就会自动进行数据类型转换。SpringMVC提供了常用类型的转换器,例如String
到Integer
、String
到Date
,String
到Boolean
等等,其中也包括String
到枚举类型,但是String
到枚举类型的默认转换规则是根据实例名称(“APARTMENT”)转换为枚举对象实例(ItemType.APARTMENT)。若想实现code
属性到枚举对象实例的转换,需要自定义Converter
,代码如下,具体内容可参考官方文档。
- 在web-admin模块自定义
com.atguigu.lease.web.admin.custom.converter.StringToItemTypeConverter
@Component
public class StringToItemTypeConverter implements Converter<String, ItemType> {
@Override
public ItemType convert(String code) {
for (ItemType value : ItemType.values()) {
if (value.getCode().equals(Integer.valueOf(code))) {
return value;
}
}
throw new IllegalArgumentException("code非法");
}
}
注册上述的StringToItemTypeConverter
,在web-admin模块创建com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration
,
内容如下:
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private StringToItemTypeConverter stringToItemTypeConverter;
/*注册stringToItemTypeConverter*/
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(stringToItemTypeConverter);
}
}
addFormatters的工作原理:
但是我们有很多的枚举类型都需要考虑类型转换这个问题,按照上述思路,我们需要为每个枚举类型都定义一个Converter,并且每个Converter的转换逻辑都完全相同,针对这种情况,我们使用ConverterFactory
接口更为合适,这个接口可以将同一个转换逻辑应用到一个接口的所有实现类,因此我们可以定义一个BaseEnum
接口,然后另所有的枚举类都实现该接口,然后就可以自定义ConverterFactory
,集中编写各枚举类的转换逻辑了。具体实现如下:
-
在model模块定义
com.atguigu.lease.model.enums.BaseEnum
接口public interface BaseEnum { Integer getCode(); String getName(); }
-
令所有
com.atguigu.lease.model.enums
包下的枚举类都实现BaseEnun
接口 -
在web-admin模块自定义
com.atguigu.lease.web.admin.custom.converter.StringToBaseEnumConverterFactory
/**
* 统一将String转换为BaseEnum
* 执行逻辑为:当SpringMvc要进行String -> BaseEnum(包括子类)型转换时就会执行Converter()方法
*/
@Component
public class StringToBaseEnumConverterFactory implements ConverterFactory<String, BaseEnum> {
/**
* 当状态类的枚举过多时可以使用converterFactory(转换器工厂)进行统一转换(前提是原数据类型相同【String】,转换的数据类型都有统一的继承【BaseEnum】)
* @param targetType 【转化的类型】
* @return 返回值可以直接使用new Converter...进行设置
* @param <T> 表示继承于BaseEnum的所有对象
*/
@Override
public <T extends BaseEnum> Converter<String, T> getConverter(Class<T> targetType) {
/*Converter(转换器)*/
return new Converter<String, T>() {
@Override
public T convert(String source) {
/**
* targetType.getEnumConstants:遍历枚举常量
*/
for (T enumConstant : targetType.getEnumConstants()) {
/*判断code是否与参数source相等,相等代表这个枚举常量是目标则返回*/
if (enumConstant.getCode().equals(Integer.parseInt(source))) {
return enumConstant;
}
}
throw new RuntimeException("code:" + source + "code非法");
}
};
}
}
- 注册上述的
ConverterFactory
,在web-admin模块创建com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration
,内容如下:
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private StringToBaseEnumConverterFactory stringToBaseEnumConverterFactory;
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(this.stringToBaseEnumConverterFactory);
}
}
此时请求参数已经可以转化为对应枚举对象了,但是但是还需要配置MyBatisPlus映射到数据库的类型转化
-
ypeHandler枚举类型转换**
Mybatis预置的
TypeHandler
可以处理常用的数据类型转换,例如String
、Integer
、Date
等等,其中也包含枚举类型,但是枚举类型的默认转换规则是枚举对象实例(ItemType.APARTMENT)和实例名称(“APARTMENT”)相互映射。若想实现code
属性到枚举对象实例的相互映射,需要自定义TypeHandler
。不过MybatisPlus提供了一个通用的处理枚举类型的TypeHandler。其使用十分简单,只需在
ItemType
枚举类的code
属性上增加一个注解@EnumValue
,Mybatis-Plus便可完成从ItemType
对象到code
属性之间的相互映射,具体配置如下。
public enum ItemType {
APARTMENT(1, "公寓"),
ROOM(2, "房间");
@EnumValue
private Integer code;
private String name;
ItemType(Integer code, String name) {
this.code = code;
this.name = name;
}
}
-
HTTPMessageConverter枚举类型转换
HttpMessageConverter
依赖于Json序列化框架(默认使用Jackson)。其对枚举类型的默认处理规则也是枚举对象实例(ItemType.APARTMENT)和实例名称(“APARTMENT”)相互映射。不过其提供了一个注解@JsonValue
,同样只需在ItemType
枚举类的code
属性上增加一个注解@JsonValue
,Jackson便可完成从ItemType
对象到code
属性之间的互相映射。具体配置如下,详细信息可参考Jackson官方文档。
@Getter
public enum ItemType {
APARTMENT(1, "公寓"),
ROOM(2, "房间");
@EnumValue
@JsonValue
private Integer code;
private String name;
ItemType(Integer code, String name) {
this.code = code;
this.name = name;
}
}
2、全局异常
官网地址:https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-exceptionhandler.html
可以把他理解为对项目异常的拦截器,每当报某一个Exception
时便可以自定以操作(例如:直接响应Result.fail)
使用创建一个标记注解@ControllerAdvice
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
/*将返回值序列化为Http响应体*/
@ResponseBody
public Result exceptionHanler(Exception e) {
e.printStackTrace();
return Result.fail();
}
@ExceptionHandler(LeaseException.class)
@ResponseBody
public Result LeaseExceptionHandler(LeaseException e) {
return Result.handler(e.getMessage(), e.getCode());
}
}
3、定时任务
当想要完成一个根据时间动态的去执行某一个逻辑,这时就可以使用springboot中提供的定时任务
1. 核心步骤
(1) 启用定时任务
在主配置类或任意 @Configuration
类上添加 @EnableScheduling
:
@SpringBootApplication
@EnableScheduling // 启用定时任务支持
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
(2) 创建定时任务
在 Bean 的方法上使用 @Scheduled
注解:
@Component
public class MyTaskScheduler {
// 固定频率执行(每 5 秒)
@Scheduled(fixedRate = 5000) // 单位:毫秒
public void task1() {
System.out.println("固定频率任务执行: " + LocalDateTime.now());
}
// 固定延迟执行(上次任务结束后延迟 3 秒)
@Scheduled(fixedDelay = 3000)
public void task2() {
System.out.println("固定延迟任务执行: " + LocalDateTime.now());
}
// 使用 Cron 表达式(每天 12:00 执行)
@Scheduled(cron = "0 0 12 * * ?")
public void task3() {
System.out.println("Cron 任务执行: " + LocalDateTime.now());
}
}
2. @Scheduled
参数详解
参数 | 说明 | 示例 |
---|---|---|
fixedRate | 固定频率(毫秒),上次开始时间后间隔执行 | fixedRate = 5000 |
fixedDelay | 固定延迟(毫秒),上次结束时间后间隔执行 | fixedDelay = 3000 |
initialDelay | 首次延迟(毫秒),需配合 fixedRate/fixedDelay 使用 | initialDelay = 10000 |
cron | Cron 表达式(支持秒级) | cron = "0 15 10 * * ?" |
zone | 时区(默认为服务器时区) | zone = "Asia/Shanghai" |
3. Cron 表达式语法
Spring Boot 支持 6位 Cron 表达式(包含秒):
秒 分 时 日 月 周
常用示例:
0 * * * * ?
:每分钟的 0 秒执行0 0 10 * * ?
:每天 10:00 执行0 0/5 14 * * ?
:每天 14:00-14:55,每 5 分钟执行0 15 10 ? * MON-FRI
:周一至周五 10:15 执行
4. 配置线程池(避免阻塞)
默认所有任务在单线程中执行。若需并发,需自定义线程池:
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(5); // 线程池大小
taskScheduler.setThreadNamePrefix("my-scheduler-");
taskScheduler.initialize();
taskRegistrar.setTaskScheduler(taskScheduler);
}
}
5. 动态控制任务(高级用法)
通过 ScheduledTaskRegistrar
动态注册/取消任务:
@Service
public class DynamicTaskService {
@Autowired
private ScheduledTaskRegistrar taskRegistrar;
private ScheduledFuture<?> future;
// 启动任务
public void startTask() {
future = taskRegistrar.getScheduler().schedule(
() -> System.out.println("动态任务执行: " + LocalDateTime.now()),
new CronTrigger("0/10 * * * * ?") // 每 10 秒
);
}
// 停止任务
public void stopTask() {
if (future != null) {
future.cancel(true); // 取消任务
}
}
}
6. 注意事项
- 避免长时间阻塞:任务执行时间 > 间隔时间会导致任务堆积(需优化或异步处理)。
- 单线程问题:默认单线程顺序执行,耗时任务需配置线程池。
- 分布式环境:集群中多个节点会同时执行任务(需额外方案如分布式锁或
Quartz
集成)。
4、MyBatisPlus的修改策略
使用场景:
当使用MyBatisPlus提供的update的APi时会发现,
他会做一个判断当某一个字段的值为null时便不会更新此字段,
而若是想要更新就需要去配置MyBatisPlus中的修改策略(update-strategy)
在 MyBatis-Plus 中,字段的更新策略(update-strategy
)主要通过 @TableField
注解的 updateStrategy
属性或全局配置来控制。以下是详细说明和配置方法:
1. 更新策略类型(FieldStrategy 枚举)
策略 | 说明 |
---|---|
IGNORED | 忽略判断:无论字段值是什么,都会更新到数据库(即使为 null) |
NOT_NULL | 非 NULL 判断:字段值不为 null 时才更新(默认值) |
NOT_EMPTY | 非空判断:字段值不为 null 且不为空(如字符串长度>0)才更新 |
NEVER | 永不更新:该字段永远不会被更新到数据库 |
DEFAULT | 跟随全局配置 |
2. 配置方式
(1) 字段级配置(注解方式:局部配置)
在实体类字段上使用 @TableField
注解:
public class User {
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String email; // 总是更新
@TableField(updateStrategy = FieldStrategy.NOT_EMPTY)
private String nickname; // 非空时才更新
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime createTime; // 永不更新
}
(2) 全局配置(application.yml)
mybatis-plus:
global-config:
db-config:
update-strategy: not_null # 全局默认策略
# 可选值: ignored, not_null, not_empty, never, default
优先级:字段注解 > 全局配置
3. 使用场景示例
场景 1:允许更新为 null 值
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String phone; // 可更新为 null
场景 2:空值不覆盖数据库原有值
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private Integer age; // 若 age=null 则跳过更新
场景 3:创建时间永不更新
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime createTime;
4. 更新操作行为说明
- 使用
updateById()
:
User user = new User();
user.setId(1L);
user.setEmail(null); // 策略=IGNORED → 更新为 null
user.setNickname(""); // 策略=NOT_EMPTY → 跳过更新
userMapper.updateById(user);
生成 SQL:
UPDATE user SET email = null WHERE id = 1;
- 使用
updateWrapper
:
通过 Wrapper 更新的字段不受策略影响,会直接更新:
new UpdateWrapper<User>()
.set("nickname", null) // 直接更新为 null
.eq("id", 1);
5. 动态更新策略技巧
通过条件构造器实现动态更新:
public void updateUserConditional(Long id, String name, String email) {
UpdateWrapper<User> wrapper = new UpdateWrapper<User>().eq("id", id);
if (name != null) {
wrapper.set("name", name); // 只有非 null 才更新 name
}
wrapper.set("email", email); // 总是更新 email
userMapper.update(null, wrapper);
}
6. 注意事项
- 策略仅影响非 null 字段:
- 如果字段在更新时未被设置(值为 null),则根据策略决定是否更新
- 如果显式设置了值(即使为 null),则受
IGNORED/NEVER
等策略控制
- 插入策略 vs 更新策略:
insertStrategy
控制插入行为(通过@TableField(insertStrategy=...)
配置)updateStrategy
仅控制更新行为
- 全局默认值变更:
- MyBatis-Plus 3.x 开始全局默认策略从
NOT_NULL
改为DEFAULT
(即不处理) - 建议显式配置全局策略
5、图形化验证码 EasyCaptcha(Gitee)
EasyCaptcha此项目使用的是在Gitee开源开源项目,
他可以自动生成一个图形化验证码,其支持多种类型的验证码,例如gif、中文、算术等。
使用方式:结合redis可以做一个图形化验证码(指定时间过期)的逻辑,
1、导入依赖
<!--captcha-->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置redis
spring:
data:
redis:
# 主机地址
host: <hostname>
# 端口
port: <port>
# 库
database: 0
2、使用示例
@Override
public CaptchaVo getCaptcha() {
/*创建随机的一个验证码图像(二进制)*/
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
/*图像对应的值,并将值转化为小写(实现不区分大小写)*/
String code = specCaptcha.text().toLowerCase();
/*为图像拼接标识唯一id,方便存储到redis中*/
String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
/*存到redis中,并设置超时时间*/
stringRedisTemplate.opsForValue()
.set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);
/*.toBase64()返回的值,前端可以直接使用赋值给img标签中src属性,自动显示一个验证码*/
String image = specCaptcha.toBase64();
return new CaptchaVo(image, key);
}
/*配置redis常量*/
public class RedisConstant {
public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
}
6、线程本地(ThreadLocal)储存个人信息
当JWT中存储着个人信息(id、userName),
此时想要查询详细信息便可以:解析JWT获取id->根据id查询信息->返回此方案可以完成业务,但是在拦截器中我们也会对JWT进行解析判断是否合法,会重复的解析JWT,此时便可以使用本地线程(ThreadLocal)储存个人信息
实现步骤:
- 创建个人信息的对象
public class LoginUser {
private String userName;
private Long userId;
}
-
请求的数据结构
按理说,前端若想获取当前登录用户的个人信息,需要传递当前用户的id
到后端进行查询。但是由于请求中携带的JWT中就包含了当前登录用户的id
,故请求个人信息时,就无需再传递id
。 -
编写ThreadLocal工具类
理论上我们可以在Controller方法中,使用
@RequestHeader
获取JWT,然后在进行解析,如下@Operation(summary = "获取登陆用户个人信息") @GetMapping("info") public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) { Claims claims = JwtUtil.parseToken(token); Long userId = claims.get("userId", Long.class); SystemUserInfoVo userInfo = service.getLoginUserInfo(userId); return Result.ok(userInfo); }
上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。
ThreadLocal概述
ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,使每个线程都可以操作自己的变量,而不会互相干扰,其用法如下图所示。
创建本地线程的工具类:
public class LoginUserHolder {
/*创建本地线程*/
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
/*获取本地线程资源*/
public static LoginUser getLoginUser() {
return threadLocal.get();
}
/*添加本地线程的资源*/
public static void setThreadLocal(LoginUser loginUser) {
threadLocal.set(loginUser);
}
/*清除线程占用的资源*/
public static void clear() {
threadLocal.remove();
}
}
- 修改
AuthenticationInterceptor
拦截器
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/*获取jwt*/
String accessToken = request.getHeader("access-token");
/*解析jwt*/
Claims claims = JwtUtil.parseJwt(accessToken);
/*获取个人信息对象*/
LoginUser loginUser = new LoginUser(claims.get("userName", String.class), claims.get("userId", Long.class));
/*添加本地线程资源*/
LoginUserHolder.setThreadLocal(loginUser);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
/*释放线程资源*/
LoginUserHolder.clear();
}
}
调用本地线程的属性:
Long userId = LoginUserHolder.getLoginUser().getUserId();
String userName = LoginUserHolder.getLoginUser().getUserName();
7、Mybatis-Plus分页插件注意事项
使用Mybatis-Plus的分页插件进行分页查询时,如果结果需要使用
<collection>
进行映射,只能使用 嵌套查询(Nested Select for Collection),而不能使用
嵌套结果映射(Nested Results for Collection)。
嵌套查询和嵌套结果映射**是Collection映射的两种方式,下面通过一个案例进行介绍
例如有room_info
和graph_info
两张表,其关系为一对多,如下
知识点:
- xml文件
<
和>
的转义
由于xml文件中的<
和>
是特殊符号,需要转义处理。
原符号 | 转义符号 |
---|---|
< | < |
> | > |
现需要查询房间列表及其图片信息,期望返回的结果如下
[
{
"id": 1,
"number": 201,
"rent": 2000,
"graphList": [
{
"id": 1,
"url": "http://",
"roomId": 1
},
{
"id": 2,
"url": "http://",
"roomId": 1
}
]
},
{
"id": 2,
"number": 202,
"rent": 3000,
"graphList": [
{
"id": 3,
"url": "http://",
"roomId": 2
},
{
"id": 4,
"url": "http://",
"roomId": 2
}
]
}
]
为得到上述结果,可使用以下两种方式
- 嵌套结果映射
<select id="selectRoomPage" resultMap="RoomPageMap">
select ri.id room_id,
ri.number,
ri.rent,
gi.id graph_id,
gi.url,
gi.room_id
from room_info ri
left join graph_info gi on ri.id=gi.room_id
</select>
<resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
<id column="room_id" property="id"/>
<collection property="graphInfoList" ofType="GraphInfo" autoMapping="true">
<id column="graph_id" property="id"/>
</collection>
</resultMap>
这种方式的执行原理如下图所示
- 嵌套查询
<select id="selectRoomPage" resultMap="RoomPageMap">
select id,
number,
rent
from room_info
</select>
<resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
<id column="id" property="id"/>
<collection property="graphInfoList" ofType="GraphInfo" select="selectGraphByRoomId" column="id"/>
</resultMap>
<select id="selectGraphByRoomId" resultType="GraphInfo">
select id,
url,
room_id
from graph_info
where room_id = #{id}
</select>
这种方法使用两个独立的查询语句来获取一对多关系的数据。首先,
Mybatis会执行主查询来获取room_info
列表,然后对于每个room_info
,
Mybatis都会执行一次子查询来获取其对应的graph_info
。
若现在使用MybatisPlus的分页插件进行分页查询,
假如查询的内容是第1页,每页2条记录,
则上述两种方式的查询结果分别是
- 嵌套结果映射
- 嵌套查询
显然嵌套结果映射的分页逻辑是存在问题的。
8、异步调用(@Async)
当执行一个业务是只需要后台内部完成的操作,不需要响应给前端(列如保存浏览记录),
便可以进行异步调用从而提升用户体验
异步调用本质
- 非阻塞执行:主线程提交任务后立即返回,不等待任务完成
- 线程池管理:任务由线程池中的工作线程执行
- 结果处理:通过 Future 或回调机制获取执行结果
实现原理
使用示例
启动异步调用
@SpringBootApplication
/*支持异步调用*/
@EnableAsync
public class MainApp {
public static void main(String[] args) {
SpringApplication.run(MainApp.class, args);
}
}
声明异步处理的方法(@Async)
@Override
/*异步调用*/
@Async
public void saveHistory(Long userId, Long roomId) {
// ...
}
此时当主线程调用此方法时便不会等待此方法执行完再返回而是直接返回。
本文档和代码以上传gitee上:https://gitee.com/banhuayue/shangting-apartment.git
大家可以直接克隆。
本文档参考《尚硅谷-尚庭公寓》