SpringBoot+MyBatis开发核心知识点总结其一

本文基于员工管理后台接口开发实战经验,从分页原理、虚拟接口路径、AOP自动填充、前后端传参、启动报错排查等核心知识点展开深度剖析。每个知识点均包含原理分析源码解读常见坑点最佳实践四个维度,适合已掌握基础用法、希望深入理解底层机制的SpringBoot开发者。


一、MyBatis分页核心原理(PageHelper源码级解析)

1. 核心结论:MyBatis原生没有分页能力

MyBatis作为ORM框架,只负责SQL与Java对象的映射,不包含任何分页逻辑。查询全表数据时,MyBatis会发送完整的SELECT * FROM table语句到数据库,数据库返回全部结果集。当数据量达到百万级别时,这条SQL会严重占用数据库IO和内存资源。

分页的核心手段是在SQL尾部追加LIMIT offset, size子句,这个功能需要借助第三方插件PageHelper实现。

2. 两行分页代码的底层关系

1
2
3
4
5
// 第一行:设置分页参数到ThreadLocal
PageHelper.startPage(pageNum, pageSize);

// 第二行:执行查询,被插件拦截增强
employeeMapper.pageQuery(dto);

源码级分析:

PageHelper.startPage(pageNum, pageSize)内部做了什么?

1
2
3
4
5
6
7
// 简化后的核心逻辑
public static <E> Page<E> startPage(int pageNum, int pageSize) {
Page<E> page = new Page<>(pageNum, pageSize);
// 将Page对象存入当前线程的ThreadLocal中
LOCAL_PAGE.set(page);
return page;
}

关键点:

  • 不发送任何SQL:该方法仅创建一个Page对象,存入当前线程的ThreadLocal
  • ThreadLocal的作用:每个线程拥有独立的存储空间,不会与其他请求相互干扰
  • 标记机制:相当于在当前线程上贴了一个“需要分页”的标签,等待后续的SQL执行时被读取

3. 分页拦截器执行全过程

employeeMapper.pageQuery(dto) 被调用时,MyBatis会经过以下拦截器链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MyBatis执行SQL

PageHelper拦截器(PageInterceptor)拦截Executor.query()方法

读取ThreadLocal中存储的Page对象(包含pageNum, pageSize)

生成分页SQL:原始SQL + LIMIT offset, size

自动执行 COUNT 查询:SELECT COUNT(*) FROM (原始SQL) tmp

将查询结果(数据列表 + 总数)封装到Page对象

清理ThreadLocal(防止内存泄漏)

返回Page对象

LIMIT关键字的数学公式:

1
2
3
4
5
6
7
8
-- 第1页,每页10条
SELECT * FROM employee LIMIT 0, 10; -- offset = (1-1)*10 = 0

-- 第2页,每页10条
SELECT * FROM employee LIMIT 10, 10; -- offset = (2-1)*10 = 10

-- 第3页,每页10条
SELECT * FROM employee LIMIT 20, 10; -- offset = (3-1)*10 = 20

性能优化建议:

  • 禁止使用 LIMIT 100000, 10 这种深分页,因为MySQL需要扫描前100010条数据再丢弃前100000条
  • 深分页优化方案:使用游标分页(WHERE id > lastId LIMIT 10)代替偏移量分页

4. Page对象的内部结构

PageHelper自动封装的Page对象继承自ArrayList,除了包含查询结果列表,还额外存储了分页元数据:

1
2
3
4
5
6
7
public class Page<T> extends ArrayList<T> {
private int pageNum; // 当前页码
private int pageSize; // 每页条数
private long total; // 总记录数
private int pages; // 总页数
// ... 其他字段
}

自定义PageResult封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class PageResult<T> {
private long total; // 总记录数
private List<T> records; // 当前页数据列表
private int pages; // 总页数
private int pageNum; // 当前页码

public static <T> PageResult<T> of(Page<T> page) {
PageResult<T> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(page.getResult()); // 注意:getResult()获取当前页数据
result.setPages(page.getPages());
result.setPageNum(page.getPageNum());
return result;
}
}

常见踩坑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 错误写法:直接返回Page对象
public Page<Employee> pageQuery(EmployeePageQueryDTO dto) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
return employeeMapper.pageQuery(dto); // 返回了Page对象,破坏了统一封装规范
}

// 正确写法:封装为自定义PageResult
public PageResult<EmployeeVO> pageQuery(EmployeePageQueryDTO dto) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Employee> page = employeeMapper.pageQuery(dto);
// 将Employee转换为EmployeeVO(净化敏感字段)
List<EmployeeVO> voList = page.getResult().stream()
.map(this::toVO)
.collect(Collectors.toList());
return PageResult.of(page.getTotal(), voList);
}

5. 变量作用域与数据流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 错误示范
public PageResult<Employee> pageQuery(EmployeePageQueryDTO dto) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
List<Employee> employees = employeeMapper.pageQuery(dto);
// 这里employees是List<Employee>,不是Page对象,无法获取total
PageResult<Employee> result = new PageResult<>();
result.setRecords(employees); // 正确
result.setTotal(employees.size()); // 错误!这是当前页条数,不是总条数
return result;
}

// 正确写法
public PageResult<Employee> pageQuery(EmployeePageQueryDTO dto) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Employee> page = (Page<Employee>) employeeMapper.pageQuery(dto);
// Page对象继承了ArrayList,所以可以直接赋值给List,但需要显式强转来调用getTotal()
PageResult<Employee> result = new PageResult<>();
result.setRecords(page.getResult()); // 当前页数据
result.setTotal(page.getTotal()); // 数据库中的总记录数
return result;
}

二、接口请求路径深度解析

1. 虚拟路径与物理路径的本质区别

物理路径示例:

1
/static/images/logo.png  → 映射到服务器磁盘 /var/www/static/images/logo.png

虚拟路径示例:

1
2
3
@PostMapping("/api/employee/status/{status}")
→ 不指向任何文件系统路径,只匹配URL模式
→ 匹配成功后,由DispatcherServlet分派给对应Controller方法执行业务逻辑

多级路径映射规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/api") // 一级路径
public class EmployeeController {

@GetMapping("/employee") // 二级路径 → 完整路径: /api/employee
public Result list() { ... }

@PostMapping("/employee/{id}") // 带路径变量 → /api/employee/10
public Result getById(@PathVariable Long id) { ... }

@PutMapping("/employee/{id}/status/{status}") // 多级路径变量 → /api/employee/10/status/1
public Result updateStatus(@PathVariable Long id, @PathVariable Integer status) { ... }
}

2. @PathVariable与@RequestParam的深度对比

特性 @PathVariable @RequestParam
取值方式 URL路径中取值 /user/10 URL问号后取值 /user?id=10
必须性 默认必须,可设置required=false 默认必须,可设置required=false
默认值 不支持 支持 defaultValue
适用场景 RESTful资源标识 查询条件、过滤参数
多值支持 不支持 支持 @RequestParam List ids

实际开发案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 路径变量:适用于资源标识
@GetMapping("/employees/{id}")
public Result getEmployee(@PathVariable Long id) {
// 获取id为10的员工信息
}

// 请求参数:适用于查询条件
@GetMapping("/employees")
public Result listEmployees(
@RequestParam(required = false) String name, // 模糊搜索
@RequestParam(required = false) Integer status, // 状态过滤
@RequestParam(defaultValue = "1") Integer page, // 当前页,默认第1
@RequestParam(defaultValue = "10") Integer size // 每页条数,默认10
) {
// 查询符合条件的员工列表
}

3. 员工启用禁用接口的完整实现

请求设计:

1
2
PUT /api/employee/status
请求体:{"id": 10, "status": 1} // 1启用 0禁用

Controller层:

1
2
3
4
5
6
@PutMapping("/status")
public Result updateStatus(@RequestBody EmployeeStatusDTO dto) {
log.info("修改员工状态: id={}, status={}", dto.getId(), dto.getStatus());
employeeService.updateStatus(dto);
return Result.success();
}

Service层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class EmployeeServiceImpl implements EmployeeService {

@Override
public void updateStatus(EmployeeStatusDTO dto) {
// 1. 构建实体对象
Employee employee = Employee.builder()
.id(dto.getId())
.status(dto.getStatus())
.updateTime(LocalDateTime.now()) // 手动设置或通过AOP自动填充
.updateUser(BaseContext.getCurrentId())
.build();

// 2. 执行更新
employeeMapper.update(employee);
}
}

Mapper层(XML方式):

1
2
3
4
5
6
7
8
9
<update id="update" parameterType="Employee">
UPDATE employee
<set>
<if test="status != null">status = #{status},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateUser != null">update_user = #{updateUser},</if>
</set>
WHERE id = #{id}
</update>

三、AOP切面自动填充公共字段

1. @AutoFill注解的设计原理

背景痛点:
每个实体类都有 createTimecreateUserupdateTimeupdateUser 四个字段。如果每次新增/修改都手动set,会产生大量重复代码。

解决方案:
通过自定义注解 + AOP切面,自动完成公共字段赋值。

2. 完整实现步骤

步骤一:定义注解

1
2
3
4
5
@Target(ElementType.METHOD)          // 注解作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留
public @interface AutoFill {
OperationType value(); // 指定操作类型(INSERT / UPDATE)
}

步骤二:定义枚举

1
2
3
4
public enum OperationType {
INSERT, // 新增操作,需要填充四个字段
UPDATE // 更新操作,只需要填充修改时间、修改人
}

步骤三:ThreadLocal工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

public static void setCurrentId(Long id) {
threadLocal.set(id);
}

public static Long getCurrentId() {
return threadLocal.get();
}

public static void removeCurrentId() {
threadLocal.remove(); // 防止内存泄漏
}
}

设置当前用户ID的时机(一般在拦截器或过滤器中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class JwtTokenInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 解析token获取用户ID
String token = request.getHeader("token");
Long userId = JwtUtil.parseToken(token);
// 存入ThreadLocal
BaseContext.setCurrentId(userId);
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
BaseContext.removeCurrentId(); // 请求结束后必须清理
}
}

步骤四:定义切面类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Aspect
@Component
@Slf4j
public class AutoFillAspect {

@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {}

@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
log.info("开始进行公共字段自动填充...");

// 1. 获取当前操作类型(INSERT / UPDATE)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();

// 2. 获取参数:约定第一个参数是实体类
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) {
return;
}
Object entity = args[0];

// 3. 准备赋值数据
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();

// 4. 根据操作类型赋值
if (operationType == OperationType.INSERT) {
try {
// 设置创建时间
entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class)
.invoke(entity, now);
// 设置创建人
entity.getClass().getDeclaredMethod("setCreateUser", Long.class)
.invoke(entity, currentId);
// 设置修改时间
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class)
.invoke(entity, now);
// 设置修改人
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class)
.invoke(entity, currentId);
} catch (Exception e) {
log.error("公共字段自动填充失败: {}", e.getMessage());
throw new RuntimeException("公共字段自动填充失败", e);
}
} else if (operationType == OperationType.UPDATE) {
try {
// 设置修改时间
entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class)
.invoke(entity, now);
// 设置修改人
entity.getClass().getDeclaredMethod("setUpdateUser", Long.class)
.invoke(entity, currentId);
} catch (Exception e) {
log.error("公共字段自动填充失败: {}", e.getMessage());
throw new RuntimeException("公共字段自动填充失败", e);
}
}
}
}

3. 使用方式

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface EmployeeMapper {

@AutoFill(OperationType.INSERT)
@Insert("INSERT INTO employee(name, username) VALUES(#{name}, #{username})")
void insert(Employee employee); // 插入时自动填充4个字段

@AutoFill(OperationType.UPDATE)
void update(Employee employee); // 更新时自动填充2个字段
}

4. 反射机制解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反射调用set方法的等价代码(无需反射的情况)
employee.setCreateTime(LocalDateTime.now());

// 反射写法的作用
// 1. 解耦:切面不需要知道具体是哪个实体类
// 2. 通用:可以应用于任意实体类,只要包含这些字段
// 3. 动态:在运行时根据方法上的注解动态决定赋值逻辑

// getDeclaredMethod参数说明
entity.getClass() // 获取实体类的Class对象
.getDeclaredMethod("setCreateTime", // 方法名:setCreateTime
LocalDateTime.class) // 参数类型:LocalDateTime
.invoke(entity, // 目标对象:实体类实例
now); // 实际参数:当前时间

四、SpringBoot项目启动报错排查指南

1. Bean创建失败的常见原因

错误信息:

1
Field employeeMapper in com.sky.service.impl.EmployeeServiceImpl required a bean of type 'com.sky.mapper.EmployeeMapper' that could not be found.

排查步骤:

原因 检查项 解决方案
未加@Autowired Service层是否加了@Autowired注入 添加@Autowired注解
未加@Mapper Mapper接口是否加了@Mapper 添加@Mapper注解
未配置扫描路径 启动类是否加了@MapperScan 添加@MapperScan(“com.sky.mapper”)
接口未创建实现类 是否存在对应ServiceImpl 创建并添加@Service注解

2. 数据库连接失败排查

错误信息:

1
Cannot create PoolableConnectionFactory (Access denied for user 'root'@'localhost')

application.yml配置检查清单:

1
2
3
4
5
6
spring:
datasource:
url: jdbc:mysql://localhost:3306/sky_take_out?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8
username: root # 确认用户名正确
password: 123456 # 确认密码正确
driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8.x必须用cj驱动

常见问题:

  • MySQL服务未启动:net start mysql
  • 数据库名拼写错误:sky_take_out 与实际库名不一致
  • 时区配置不对:serverTimezone=Asia/Shanghai 替换UTC

3. AOP切面报错

典型错误:

1
java.lang.NoSuchMethodException: com.sky.entity.Employee.setUpdateUser(java.lang.Long)

原因分析:

  1. 方法名不一致:实体类中字段名为updateUser,但set方法名是setUpdateUser(首字母大写)
  2. 参数类型不匹配:实体类字段类型是Long,但反射调用的参数类型是Integer
  3. 字段缺失:实体类中根本没有updateTime这个字段

解决方案:

1
2
3
4
5
6
7
8
// 确保实体类字段名和方法名规范
public class Employee {
private Long updateUser; // 字段名

public void setUpdateUser(Long updateUser) { // 方法名:set + 字段名首字母大写
this.updateUser = updateUser;
}
}

4. 消息转换器扩展

问题背景:
SpringBoot默认使用Jackson进行JSON序列化,存在两个常见问题:

问题一:日期格式错误

1
2
// 默认序列化结果
"updateTime": "2024-01-15T10:30:00" // 不符合前端期望格式

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();

// 1. 日期格式化
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

// 2. 时区设置
objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));

// 3. Long类型转字符串(解决前端精度丢失)
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(module);

converter.setObjectMapper(objectMapper);
converters.add(0, converter); // 添加到第一位,优先级最高
}
}

问题二:Long类型前端精度丢失

1
2
Java: 189732648726348762L
JavaScript: 189732648726348760 (精度丢失)

解决方案: 将Long序列化为字符串


五、高级开发规范与最佳实践

1. 异常处理规范

全局异常处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public Result handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(e.getMessage());
}

@ExceptionHandler(Exception.class)
public Result handleException(Exception e) {
log.error("系统异常: ", e);
return Result.error("服务器繁忙,请稍后重试");
}
}

2. 参数校验规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PostMapping("/employee")
public Result save(@Valid @RequestBody EmployeeDTO dto) {
// @Valid 触发参数校验
employeeService.save(dto);
return Result.success();
}

// DTO定义校验规则
@Data
public class EmployeeDTO {
@NotBlank(message = "用户名不能为空")
private String username;

@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}

3. 日志规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
@Slf4j
public class EmployeeServiceImpl {

public void save(EmployeeDTO dto) {
log.info("开始新增员工: {}", dto.getUsername()); // INFO级别记录关键操作

try {
// 业务逻辑
} catch (Exception e) {
log.error("新增员工失败: username={}, 错误原因: {}", dto.getUsername(), e.getMessage());
throw new BusinessException("新增员工失败");
}

log.info("新增员工成功: id={}", employee.getId());
}
}