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
| 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); 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
| SELECT * FROM employee LIMIT 0, 10;
SELECT * FROM employee LIMIT 10, 10;
SELECT * FROM employee LIMIT 20, 10;
|
性能优化建议:
- 禁止使用
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()); 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
| public Page<Employee> pageQuery(EmployeePageQueryDTO dto) { PageHelper.startPage(dto.getPage(), dto.getPageSize()); return employeeMapper.pageQuery(dto); }
public PageResult<EmployeeVO> pageQuery(EmployeePageQueryDTO dto) { PageHelper.startPage(dto.getPage(), dto.getPageSize()); Page<Employee> page = employeeMapper.pageQuery(dto); 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); 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); 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") public Result list() { ... } @PostMapping("/employee/{id}") public Result getById(@PathVariable Long id) { ... } @PutMapping("/employee/{id}/status/{status}") 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) { }
@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) { Employee employee = Employee.builder() .id(dto.getId()) .status(dto.getStatus()) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build(); 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注解的设计原理
背景痛点:
每个实体类都有 createTime、createUser、updateTime、updateUser 四个字段。如果每次新增/修改都手动set,会产生大量重复代码。
解决方案:
通过自定义注解 + AOP切面,自动完成公共字段赋值。
2. 完整实现步骤
步骤一:定义注解
1 2 3 4 5
| @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AutoFill { OperationType value(); }
|
步骤二:定义枚举
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) { String token = request.getHeader("token"); Long userId = JwtUtil.parseToken(token); 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("开始进行公共字段自动填充..."); MethodSignature signature = (MethodSignature) joinPoint.getSignature(); AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class); OperationType operationType = autoFill.value(); Object[] args = joinPoint.getArgs(); if (args == null || args.length == 0) { return; } Object entity = args[0]; LocalDateTime now = LocalDateTime.now(); Long currentId = BaseContext.getCurrentId(); 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); @AutoFill(OperationType.UPDATE) void update(Employee employee); }
|
4. 反射机制解析
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| employee.setCreateTime(LocalDateTime.now());
entity.getClass() .getDeclaredMethod("setCreateTime", LocalDateTime.class) .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服务未启动:
net start mysql
- 数据库名拼写错误:
sky_take_out 与实际库名不一致
- 时区配置不对:
serverTimezone=Asia/Shanghai 替换UTC
3. AOP切面报错
典型错误:
1
| java.lang.NoSuchMethodException: com.sky.entity.Employee.setUpdateUser(java.lang.Long)
|
原因分析:
- 方法名不一致:实体类中字段名为
updateUser,但set方法名是setUpdateUser(首字母大写)
- 参数类型不匹配:实体类字段类型是
Long,但反射调用的参数类型是Integer
- 字段缺失:实体类中根本没有
updateTime这个字段
解决方案:
1 2 3 4 5 6 7 8
| public class Employee { private Long updateUser; public void setUpdateUser(Long updateUser) { 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(); objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); 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) { employeeService.save(dto); return Result.success(); }
@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()); try { } catch (Exception e) { log.error("新增员工失败: username={}, 错误原因: {}", dto.getUsername(), e.getMessage()); throw new BusinessException("新增员工失败"); } log.info("新增员工成功: id={}", employee.getId()); } }
|