Spring 深入篇:MVC 请求处理 / MyBatis / 注解机制
本文是 Spring 学习文档的第三篇深入篇,聚焦三个最高频的实战主题:Spring MVC 请求处理全流程、MyBatis 数据访问、Java 注解机制。理解这三个,开发体验会有质的提升。
目录
第一部分:Spring MVC 请求处理流程深入
- 从一次浏览器请求说起
- DispatcherServlet:请求的总入口
- 完整的请求处理九步流程
- HandlerMapping:路由如何匹配
- HandlerAdapter:方法如何被调用
- [参数解析:@RequestBody、@RequestParam 等是怎么工作的](#参数解析:@RequestBody、@RequestParam 等是怎么工作的 "#6-%E5%8F%82%E6%95%B0%E8%A7%A3%E6%9E%90requestbodyrequestparam-%E7%AD%89%E6%98%AF%E6%80%8E%E4%B9%88%E5%B7%A5%E4%BD%9C%E7%9A%84")
- [返回值处理:对象如何变成 JSON](#返回值处理:对象如何变成 JSON "#7-%E8%BF%94%E5%9B%9E%E5%80%BC%E5%A4%84%E7%90%86%E5%AF%B9%E8%B1%A1%E5%A6%82%E4%BD%95%E5%8F%98%E6%88%90-json")
- [拦截器(Interceptor)vs 过滤器(Filter)vs AOP](#拦截器(Interceptor)vs 过滤器(Filter)vs AOP "#8-%E6%8B%A6%E6%88%AA%E5%99%A8interceptorvs-%E8%BF%87%E6%BB%A4%E5%99%A8filtervs-aop")
- 全局异常处理深入
- 文件上传、跨域、统一响应
- [MVC 常见问题排查](#MVC 常见问题排查 "#11-mvc-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5")
第二部分:MyBatis 深入
- [MyBatis 与 JPA 的本质区别](#MyBatis 与 JPA 的本质区别 "#12-mybatis-%E4%B8%8E-jpa-%E7%9A%84%E6%9C%AC%E8%B4%A8%E5%8C%BA%E5%88%AB")
- [MyBatis 核心组件](#MyBatis 核心组件 "#13-mybatis-%E6%A0%B8%E5%BF%83%E7%BB%84%E4%BB%B6")
- [XML Mapper 完整写法](#XML Mapper 完整写法 "#14-xml-mapper-%E5%AE%8C%E6%95%B4%E5%86%99%E6%B3%95")
- [注解 Mapper 写法](#注解 Mapper 写法 "#15-%E6%B3%A8%E8%A7%A3-mapper-%E5%86%99%E6%B3%95")
- [动态 SQL:MyBatis 最强大的特性](#动态 SQL:MyBatis 最强大的特性 "#16-%E5%8A%A8%E6%80%81-sqlmybatis-%E6%9C%80%E5%BC%BA%E5%A4%A7%E7%9A%84%E7%89%B9%E6%80%A7")
- 结果映射(ResultMap)与关联查询
- 一级缓存与二级缓存
- MyBatis-Plus:开发效率倍增器
- [分页插件 PageHelper](#分页插件 PageHelper "#20-%E5%88%86%E9%A1%B5%E6%8F%92%E4%BB%B6-pagehelper")
- [MyBatis 常见坑](#MyBatis 常见坑 "#21-mybatis-%E5%B8%B8%E8%A7%81%E5%9D%91")
第三部分:注解机制深入
- 注解的本质是什么
- 自定义注解的语法
- 元注解:注解的注解
- 注解的三种保留策略
- 运行时如何读取注解:反射
- [Spring 是怎么"理解"注解的](#Spring 是怎么"理解"注解的 "#27-spring-%E6%98%AF%E6%80%8E%E4%B9%88%E7%90%86%E8%A7%A3%E6%B3%A8%E8%A7%A3%E7%9A%84")
- [组合注解(Composed Annotation)](#组合注解(Composed Annotation) "#28-%E7%BB%84%E5%90%88%E6%B3%A8%E8%A7%A3composed-annotation")
- 实战:自定义业务注解的几个例子
- [注解 vs XML vs 编程式配置](#注解 vs XML vs 编程式配置 "#30-%E6%B3%A8%E8%A7%A3-vs-xml-vs-%E7%BC%96%E7%A8%8B%E5%BC%8F%E9%85%8D%E7%BD%AE")
第一部分:Spring MVC 请求处理流程深入
1. 从一次浏览器请求说起
假设前端发起这样一个请求:
http
POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGc...
{
"name": "Alice",
"email": "alice@example.com",
"age": 25
}
Spring 后端这边的代码是:
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public UserResponse createUser(@Valid @RequestBody UserCreateRequest req) {
return userService.create(req);
}
}
问题来了 :这中间发生了什么?为什么 @RequestBody 就能拿到 JSON 解析后的对象?为什么返回一个对象就能变成 JSON 响应?
接下来我们逐层剖析。
2. DispatcherServlet:请求的总入口
2.1 它的角色
DispatcherServlet 是 Spring MVC 的前端控制器(Front Controller),所有 HTTP 请求都先经过它,由它来"调度"该交给谁处理。
类比前端:类似 React Router 的 <Router> 组件------所有路由请求都先到它这里,再分发到具体的页面组件。
2.2 启动时它做了什么
Spring Boot 启动时,自动配置完成了这些事:
- 启动内嵌 Tomcat
- 注册
DispatcherServlet,映射到/(接管所有请求) - 初始化九大组件(下面会讲)
- 扫描所有
@Controller、@RestController,注册到路由表
2.3 DispatcherServlet 的九大组件
| 组件 | 作用 |
|---|---|
HandlerMapping |
找到处理请求的 Controller 方法 |
HandlerAdapter |
实际调用 Controller 方法 |
HandlerExceptionResolver |
处理异常 |
ViewResolver |
解析视图(前后端分离时基本不用) |
LocaleResolver |
国际化解析 |
ThemeResolver |
主题解析 |
MultipartResolver |
文件上传解析 |
RequestToViewNameTranslator |
默认视图名生成 |
FlashMapManager |
Flash 属性管理(重定向数据) |
重点关注 :HandlerMapping、HandlerAdapter、HandlerExceptionResolver、MultipartResolver。
3. 完整的请求处理九步流程
3.1 流程图
scss
浏览器请求
↓
[1] Filter 链(Servlet 过滤器)
↓
[2] DispatcherServlet 接收请求
↓
[3] HandlerMapping 查找处理器
↓
[4] HandlerInterceptor.preHandle()(拦截器前置)
↓
[5] HandlerAdapter 调用 Controller 方法
├─ 参数解析(HandlerMethodArgumentResolver)
├─ 执行业务逻辑
└─ 返回值处理(HandlerMethodReturnValueHandler)
↓
[6] HandlerInterceptor.postHandle()(拦截器后置)
↓
[7] 视图渲染(前后端分离时基本跳过)
↓
[8] HandlerInterceptor.afterCompletion()(拦截器最后)
↓
[9] 响应返回,Filter 链反向
↓
浏览器收到响应
3.2 异常路径
任何一步抛异常都会进入 HandlerExceptionResolver 处理:
java
任意步骤抛异常
↓
HandlerExceptionResolver 链
├─ ExceptionHandlerExceptionResolver(处理 @ExceptionHandler)
├─ ResponseStatusExceptionResolver(处理 @ResponseStatus)
└─ DefaultHandlerExceptionResolver(处理 Spring 内置异常)
↓
渲染异常响应
4. HandlerMapping:路由如何匹配
4.1 RequestMappingHandlerMapping
这是 Spring MVC 中最常用的 HandlerMapping,专门处理 @RequestMapping/@GetMapping 等注解。
启动时 ,它扫描所有 @Controller 类,把每个带 @RequestMapping 的方法注册成一个 HandlerMethod:
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) { ... }
@PostMapping
public User createUser(@RequestBody UserDto dto) { ... }
}
注册后形成一张路由表(简化示意):
| HTTP 方法 | URL 模式 | 处理方法 |
|---|---|---|
| GET | /api/users/{id} | UserController.getUser |
| POST | /api/users | UserController.createUser |
4.2 URL 匹配规则
Spring 支持多种 URL 模式:
java
@GetMapping("/users/{id}") // 路径变量
@GetMapping("/users/{id:\\d+}") // 带正则的路径变量
@GetMapping("/files/**") // 多级通配
@GetMapping("/users.{ext}") // 后缀
@GetMapping(value = "/users", params = "type=vip") // 必须有 type=vip 参数
@GetMapping(value = "/users", headers = "X-API-Version=2") // 必须有特定 Header
@GetMapping(value = "/users", consumes = "application/json") // Content-Type 必须是 JSON
@GetMapping(value = "/users", produces = "application/json") // 客户端必须接受 JSON
4.3 多个匹配怎么办
如果两个方法都匹配同一个 URL,Spring 会选更具体的那个:
java
@GetMapping("/users/{id}")
public User byId(@PathVariable Long id) { ... }
@GetMapping("/users/me")
public User me() { ... }
请求 /users/me:两个方法都匹配,但 /users/me 比 /users/{id} 更具体,会选 me() 方法。
5. HandlerAdapter:方法如何被调用
5.1 为什么需要适配器
HandlerMapping 找到了"处理器",但不同处理器调用方式不同:
@Controller的方法:用反射调用- 实现了
Controller接口的类:调handleRequest() - 实现了
HttpRequestHandler接口的类:调handleRequest()
为了统一调用,引入 HandlerAdapter------适配器模式的经典应用。
5.2 RequestMappingHandlerAdapter
这是最常用的适配器,处理 @RequestMapping 方法。它做了三件事:
- 解析方法参数 (用
HandlerMethodArgumentResolver) - 反射调用方法
- 处理返回值 (用
HandlerMethodReturnValueHandler)
5.3 调用流程伪代码
java
// 伪代码
public Object invokeMethod(HandlerMethod hm, HttpServletRequest req) {
Method method = hm.getMethod();
Parameter[] parameters = method.getParameters();
Object[] args = new Object[parameters.length];
// 第一步:解析每个参数
for (int i = 0; i < parameters.length; i++) {
for (HandlerMethodArgumentResolver resolver : resolvers) {
if (resolver.supports(parameters[i])) {
args[i] = resolver.resolveArgument(parameters[i], req);
break;
}
}
}
// 第二步:反射调用
Object result = method.invoke(hm.getBean(), args);
// 第三步:处理返回值
for (HandlerMethodReturnValueHandler handler : handlers) {
if (handler.supports(method.getReturnType())) {
handler.handleReturnValue(result, ...);
break;
}
}
return result;
}
6. 参数解析:@RequestBody、@RequestParam 等是怎么工作的
6.1 各种参数注解的对照
| 注解 | 来源 | 示例 |
|---|---|---|
@PathVariable |
URL 路径变量 | /users/{id} |
@RequestParam |
URL 查询参数或表单字段 | ?name=Alice |
@RequestBody |
请求体(JSON) | {"name": "Alice"} |
@RequestHeader |
请求头 | Authorization |
@CookieValue |
Cookie | JSESSIONID |
@ModelAttribute |
表单参数绑定到对象 | name=Alice&age=25 |
@RequestPart |
multipart 中的某一部分 | 文件上传 |
6.2 各种 Resolver 的工作原理
每种注解背后都有对应的 HandlerMethodArgumentResolver:
java
// 简化的 @RequestBody 解析器
public class RequestResponseBodyMethodProcessor {
public boolean supportsParameter(MethodParameter param) {
return param.hasParameterAnnotation(RequestBody.class);
}
public Object resolveArgument(MethodParameter param, ...) {
// 1. 读取请求体的 InputStream
InputStream body = request.getInputStream();
// 2. 根据 Content-Type 找到合适的 HttpMessageConverter
// application/json → MappingJackson2HttpMessageConverter
// application/xml → Jaxb2RootElementHttpMessageConverter
HttpMessageConverter converter = findConverter(contentType);
// 3. 用 Converter 把 InputStream 反序列化成对象
return converter.read(param.getParameterType(), body);
}
}
6.3 HttpMessageConverter:消息转换器
这是 MVC 中非常重要的组件,负责 HTTP 报文和 Java 对象之间的转换。
| Converter | 处理的 Content-Type |
|---|---|
MappingJackson2HttpMessageConverter |
application/json |
Jaxb2RootElementHttpMessageConverter |
application/xml |
StringHttpMessageConverter |
text/plain |
ByteArrayHttpMessageConverter |
application/octet-stream |
FormHttpMessageConverter |
application/x-www-form-urlencoded |
自定义 Jackson 配置:
java
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// 序列化时忽略 null
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// 日期格式
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// 支持 Java 8 时间类型
mapper.registerModule(new JavaTimeModule());
// 反序列化时忽略未知字段
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
}
6.4 参数校验机制
java
@PostMapping
public User createUser(@Valid @RequestBody UserDto dto) { // 注意 @Valid
return userService.create(dto);
}
public class UserDto {
@NotBlank(message = "用户名不能为空")
private String name;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 18, message = "未成年不允许注册")
private Integer age;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
private String phone;
}
常用校验注解:
| 注解 | 适用类型 | 含义 |
|---|---|---|
@NotNull |
任意 | 不能为 null |
@NotEmpty |
String/集合 | 不能为 null 也不能为空 |
@NotBlank |
String | 不能为 null 也不能全空白 |
@Size(min, max) |
String/集合 | 长度/大小限制 |
@Min / @Max |
数值 | 数值范围 |
@Email |
String | 邮箱格式 |
@Pattern(regexp) |
String | 正则 |
@Valid |
嵌套对象 | 触发嵌套校验 |
嵌套校验:
java
public class OrderDto {
@NotEmpty(message = "订单项不能为空")
@Valid // ← 关键!触发对集合内每个对象的校验
private List<OrderItemDto> items;
}
6.5 校验失败如何处理
@Valid 校验失败会抛出 MethodArgumentNotValidException,配合全局异常处理:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
Map<String, Object> body = Map.of(
"code", 400,
"message", "参数校验失败",
"errors", errors
);
return ResponseEntity.badRequest().body(body);
}
}
7. 返回值处理:对象如何变成 JSON
7.1 @RestController 的本质
java
@RestController // 等价于 @Controller + @ResponseBody
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
@ResponseBody 告诉 Spring:返回值不是视图名,而是要写入响应体(通常序列化为 JSON)。
7.2 返回值处理流程
typescript
Controller 方法返回 Object
↓
查找匹配的 HandlerMethodReturnValueHandler
↓
(@ResponseBody 走 RequestResponseBodyMethodProcessor)
↓
根据 Accept Header 选择 HttpMessageConverter
↓
(默认 application/json → Jackson)
↓
把对象序列化为 JSON 写入响应流
7.3 ResponseEntity:完全控制响应
java
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
if (user == null) {
return ResponseEntity.notFound().build(); // 404
}
return ResponseEntity.ok()
.header("X-Custom-Header", "value")
.body(user); // 200 + 自定义 Header
}
7.4 常用的响应快捷写法
java
return ResponseEntity.ok(user); // 200
return ResponseEntity.status(HttpStatus.CREATED).body(user); // 201
return ResponseEntity.noContent().build(); // 204
return ResponseEntity.notFound().build(); // 404
return ResponseEntity.badRequest().body("Error"); // 400
return ResponseEntity.internalServerError().build(); // 500
7.5 自定义返回字段格式(Jackson)
java
public class User {
@JsonProperty("user_id") // 字段重命名
private Long id;
@JsonIgnore // 不序列化
private String password;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonInclude(JsonInclude.Include.NON_NULL) // null 时不输出
private String email;
}
8. 拦截器(Interceptor)vs 过滤器(Filter)vs AOP
新人常被这三个搞混,搞清楚它们的层次很重要。
8.1 三者所在的层级
css
浏览器请求
↓
═══ Filter(Servlet 层)═══ ← 跨 Servlet,可以处理静态资源
↓
DispatcherServlet
↓
═══ Interceptor(Spring MVC 层)═══ ← Spring MVC 内部,只处理 Controller 请求
↓
Controller 方法
↓
═══ AOP(Spring 容器层)═══ ← 针对所有 Bean 的方法,不仅是 Web 层
↓
Service / Repository
8.2 对比表
| 维度 | Filter | Interceptor | AOP |
|---|---|---|---|
| 所属规范 | Servlet 规范 | Spring MVC | Spring AOP |
| 拦截范围 | 所有请求(含静态资源) | 只拦截 Controller 方法 | 所有 Spring Bean 方法 |
| 能否访问 Spring Bean | 需要特殊处理 | 可以(本身就是 Bean) | 可以 |
| 能否拿到 Controller 方法信息 | 否 | 是(HandlerMethod) | 是 |
| 典型场景 | 编码、CORS、登录、日志 | 权限、参数预处理 | 事务、缓存、监控 |
8.3 Filter 示例
java
@Component
@Order(1) // 多个 Filter 时排序
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
long start = System.currentTimeMillis();
chain.doFilter(req, res); // 放行
long elapsed = System.currentTimeMillis() - start;
System.out.println("请求耗时: " + elapsed + "ms");
}
}
8.4 Interceptor 示例
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler)
throws Exception {
// handler 是 HandlerMethod,可以拿到 Controller 方法信息
String token = req.getHeader("Authorization");
if (token == null) {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return false; // 返回 false 不会继续到 Controller
}
// 解析 token,把用户信息塞到 request 里供 Controller 使用
req.setAttribute("userId", parseToken(token));
return true;
}
@Override
public void postHandle(HttpServletRequest req, HttpServletResponse res,
Object handler, ModelAndView mv) { }
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
// 资源清理
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**") // 拦截的路径
.excludePathPatterns("/api/auth/login"); // 排除的路径
}
}
8.5 怎么选
- 处理静态资源、字符编码、跨域 → Filter
- 权限校验、登录检查、特定 URL 前置处理 → Interceptor
- 跨多个层的通用增强(日志、监控、事务、缓存)→ AOP
9. 全局异常处理深入
9.1 @RestControllerAdvice 完整示例
java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 业务异常
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Result.error(e.getCode(), e.getMessage()));
}
// 参数校验失败
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
return ResponseEntity.badRequest()
.body(Result.error(400, "参数错误", errors));
}
// 数据库重复键
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<Result> handleDuplicate(DuplicateKeyException e) {
return ResponseEntity.badRequest()
.body(Result.error(409, "数据已存在"));
}
// 权限不足
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Result> handleAccessDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Result.error(403, "无权访问"));
}
// 兜底
@ExceptionHandler(Exception.class)
public ResponseEntity<Result> handleAll(Exception e) {
log.error("未处理异常", e);
return ResponseEntity.internalServerError()
.body(Result.error(500, "服务器内部错误"));
}
}
9.2 自定义业务异常
java
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
}
// 使用枚举管理错误码
public enum ErrorCode {
USER_NOT_FOUND(10001, "用户不存在"),
INSUFFICIENT_BALANCE(20001, "余额不足"),
;
private final int code;
private final String message;
public BusinessException toException() {
return new BusinessException(code, message);
}
}
// 使用
if (user == null) {
throw ErrorCode.USER_NOT_FOUND.toException();
}
9.3 异常处理执行顺序
java
Controller 抛异常
↓
ExceptionHandlerExceptionResolver
├─ 先找当前 Controller 内的 @ExceptionHandler
├─ 再找 @ControllerAdvice 中的 @ExceptionHandler
└─ 选最匹配的(精确异常 > 父类异常)
↓
没找到 → ResponseStatusExceptionResolver(处理 @ResponseStatus)
↓
还没处理 → DefaultHandlerExceptionResolver(Spring 内置异常)
↓
还没处理 → 返回默认错误页 / 500
10. 文件上传、跨域、统一响应
10.1 文件上传
java
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return Result.error("文件为空");
}
String originalName = file.getOriginalFilename();
long size = file.getSize();
String contentType = file.getContentType();
// 保存
File dest = new File("/uploads/" + UUID.randomUUID() + "_" + originalName);
file.transferTo(dest);
return Result.success(Map.of("filename", dest.getName(), "size", size));
}
// 多文件
@PostMapping("/upload-multi")
public Result uploadMulti(@RequestParam("files") MultipartFile[] files) { ... }
配置 (application.yml):
yaml
spring:
servlet:
multipart:
enabled: true
max-file-size: 10MB # 单文件上限
max-request-size: 100MB # 总上限
10.2 跨域配置(CORS)
方式 1:全局配置
java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "https://example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
方式 2:单个 Controller 配置
java
@RestController
@CrossOrigin(origins = "http://localhost:3000")
public class UserController { }
方式 3:使用 CorsFilter(推荐用于 Spring Security 项目)
java
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
10.3 统一响应格式
很多项目要求所有接口返回统一格式:
json
{
"code": 0,
"message": "success",
"data": { ... }
}
定义 Result 类:
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(0, "success", data);
}
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}
}
用法:
java
@GetMapping("/{id}")
public Result<User> getUser(@PathVariable Long id) {
return Result.success(userService.findById(id));
}
进阶:用 ResponseBodyAdvice 自动包装
java
@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return !returnType.getParameterType().equals(Result.class);
}
@Override
public Object beforeBodyWrite(Object body, ...) {
return Result.success(body);
}
}
这样 Controller 直接返回业务对象,框架自动包装成 Result。
11. MVC 常见问题排查
11.1 404:找不到路径
- ✅ Controller 类上有没有
@RestController? - ✅ 方法上有没有
@GetMapping等? - ✅ 路径前后是否多了/少了
/? - ✅ 启动类是否在所有 Controller 的上层包?(
@ComponentScan默认只扫描启动类所在包及子包)
11.2 415:Unsupported Media Type
- ✅ 客户端的 Content-Type 和 Controller 的
consumes是否一致? - ✅ JSON 请求是否设置了
Content-Type: application/json?
11.3 400:Bad Request
- ✅ 参数类型是否匹配(如 String 传给 Long)?
- ✅ JSON 字段名是否对得上?
- ✅ 必填字段是否都提供了?
- ✅ 校验注解是否报错?
11.4 跨域报错
- ✅ 后端有没有配置 CORS?
- ✅ 凭证模式下(
allowCredentials = true),不能用allowedOrigins("*"),要用allowedOriginPatterns("*") - ✅ 是不是 Spring Security 拦截了 OPTIONS 预检请求?
11.5 @RequestBody 收到 null
- ✅ JSON 字段名要和 Java 字段对得上(区分大小写)
- ✅ DTO 类必须有无参构造器(不然 Jackson 反序列化失败)
- ✅ DTO 字段要有 getter/setter(或用 Lombok)
第二部分:MyBatis 深入
12. MyBatis 与 JPA 的本质区别
12.1 设计哲学差异
| 维度 | JPA / Hibernate | MyBatis |
|---|---|---|
| 类型 | 全自动 ORM | 半自动 ORM(SQL Mapper) |
| 核心 | 对象关系映射 | SQL 与 Java 方法的映射 |
| SQL | 框架生成 | 开发者自己写 |
| 灵活性 | 简单 CRUD 极快 | 复杂查询、性能调优更强 |
| 学习曲线 | 平缓 | 陡峭一点但实用 |
| 国内主流度 | 较低 | ⭐⭐⭐⭐⭐ |
12.2 一个直观对比
查询条件: 查询年龄大于 18 且姓名包含"张"的用户
JPA:
java
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByAgeGreaterThanAndNameContaining(int age, String name);
// 框架根据方法名自动生成 SQL
}
MyBatis:
xml
<select id="findActiveUsers" resultType="User">
SELECT * FROM users
WHERE age > #{age} AND name LIKE CONCAT('%', #{name}, '%')
</select>
真正复杂的查询(比如多表 join、复杂统计),MyBatis 优势就体现出来了------你想怎么写 SQL 就怎么写。
13. MyBatis 核心组件
13.1 核心对象
| 组件 | 作用 |
|---|---|
SqlSessionFactory |
会话工厂,整个应用一个,相当于"数据库连接池的入口" |
SqlSession |
会话,相当于"一次数据库交互的上下文" |
Mapper 接口 |
你写的接口,MyBatis 帮你实现 |
Mapper XML |
SQL 写在哪里 |
Configuration |
总配置 |
13.2 在 Spring Boot 中的使用流程
1. 加依赖:
xml
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
2. 配置数据库:
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: 123456
mybatis:
mapper-locations: classpath:mapper/*.xml # XML 文件位置
type-aliases-package: com.example.entity # 实体类包,用于 XML 中简写类名
configuration:
map-underscore-to-camel-case: true # user_name → userName 自动映射
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL
3. 写实体:
java
@Data
public class User {
private Long id;
private String name;
private String email;
private LocalDateTime createdAt;
}
4. 写 Mapper 接口:
java
@Mapper
public interface UserMapper {
User findById(Long id);
List<User> findAll();
int insert(User user);
int update(User user);
int deleteById(Long id);
}
5. 写 XML:
xml
<!-- resources/mapper/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="com.example.mapper.UserMapper">
<select id="findById" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
<select id="findAll" resultType="User">
SELECT * FROM users
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (name, email, created_at)
VALUES (#{name}, #{email}, #{createdAt})
</insert>
<update id="update">
UPDATE users SET name = #{name}, email = #{email}
WHERE id = #{id}
</update>
<delete id="deleteById">
DELETE FROM users WHERE id = #{id}
</delete>
</mapper>
6. 启动类加注解:
java
@SpringBootApplication
@MapperScan("com.example.mapper") // 扫描 Mapper 接口
public class Application { }
或者在每个 Mapper 接口上加 @Mapper,二选一。
13.3 接口与 XML 的对应关系
MyBatis 通过两个关键属性建立联系:
- XML 的
namespace= Mapper 接口的全限定类名 - XML 中
<select>等标签的id= 接口的方法名
ini
com.example.mapper.UserMapper
↑ (namespace)
|
<mapper namespace="com.example.mapper.UserMapper">
<select id="findById" ...> ← id 对应方法名
14. XML Mapper 完整写法
14.1 #{} 与 ${} 的区别(重要!)
xml
<!-- 用 #{}(推荐):参数会被预编译为占位符 ? -->
SELECT * FROM users WHERE name = #{name}
<!-- 实际执行:SELECT * FROM users WHERE name = ? -->
<!-- 安全:能防 SQL 注入 -->
<!-- 用 ${}:参数被直接拼接到 SQL 字符串 -->
SELECT * FROM users WHERE name = '${name}'
<!-- 危险:会导致 SQL 注入!-->
<!-- 只在动态表名、列名等场景才用 -->
规则:
- 能用
#{}就用#{} - 只有当需要动态指定表名、列名、排序字段 等不能用占位符的地方,才用
${},且参数必须严格校验
xml
<!-- 合法场景:排序字段动态化 -->
SELECT * FROM users ORDER BY ${sortField} ${sortOrder}
14.2 传递多个参数
方式 1:用 @Param 注解
java
User findByNameAndEmail(@Param("name") String name, @Param("email") String email);
xml
<select id="findByNameAndEmail" resultType="User">
SELECT * FROM users WHERE name = #{name} AND email = #{email}
</select>
方式 2:传 Map
java
List<User> search(Map<String, Object> params);
xml
<select id="search" resultType="User">
SELECT * FROM users WHERE age > #{minAge} AND name LIKE #{keyword}
</select>
方式 3:传对象
java
List<User> search(UserQuery query);
xml
<select id="search" resultType="User">
SELECT * FROM users WHERE age > #{minAge} AND name LIKE #{keyword}
</select>
14.3 insert 返回主键
xml
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO users (name, email) VALUES (#{name}, #{email})
</insert>
java
User user = new User("Alice", "alice@example.com");
userMapper.insert(user);
System.out.println(user.getId()); // 自动回填主键
14.4 批量插入
xml
<insert id="batchInsert">
INSERT INTO users (name, email) VALUES
<foreach collection="list" item="user" separator=",">
(#{user.name}, #{user.email})
</foreach>
</insert>
java
int batchInsert(@Param("list") List<User> users);
15. 注解 Mapper 写法
简单查询可以不用 XML,直接用注解:
java
@Mapper
public interface UserMapper {
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(Long id);
@Select("SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')")
List<User> findByName(String name);
@Insert("INSERT INTO users (name, email) VALUES (#{name}, #{email})")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
@Update("UPDATE users SET name = #{name} WHERE id = #{id}")
int updateName(@Param("id") Long id, @Param("name") String name);
@Delete("DELETE FROM users WHERE id = #{id}")
int deleteById(Long id);
}
注解 vs XML 怎么选:
- 简单 SQL(几行内)→ 注解更简洁
- 复杂 SQL、动态 SQL → 用 XML 更清晰
- 一个项目里最好统一风格,不要混用
16. 动态 SQL:MyBatis 最强大的特性
这是 MyBatis 远胜于其他 ORM 的地方。
16.1 <if>:条件判断
xml
<select id="search" resultType="User">
SELECT * FROM users WHERE 1=1
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="status != null">
AND status = #{status}
</if>
</select>
WHERE 1=1 是个技巧------避免拼接出 WHERE AND name = ... 这种语法错误。更优雅的做法是用下面的 <where>。
16.2 <where>:智能 WHERE
xml
<select id="search" resultType="User">
SELECT * FROM users
<where>
<if test="name != null">AND name LIKE CONCAT('%', #{name}, '%')</if>
<if test="minAge != null">AND age >= #{minAge}</if>
</where>
</select>
<where> 会自动:
- 如果有任何条件,加上
WHERE - 自动去掉第一个条件前多余的
AND/OR
16.3 <set>:智能 UPDATE
xml
<update id="updateSelective">
UPDATE users
<set>
<if test="name != null">name = #{name},</if>
<if test="email != null">email = #{email},</if>
<if test="age != null">age = #{age},</if>
</set>
WHERE id = #{id}
</update>
<set> 会自动去掉末尾多余的逗号。
16.4 <foreach>:遍历集合
IN 查询:
xml
<select id="findByIds" resultType="User">
SELECT * FROM users
WHERE id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
java
List<User> findByIds(@Param("list") List<Long> ids);
生成的 SQL :SELECT * FROM users WHERE id IN (1, 2, 3)
16.5 <choose>、<when>、<otherwise>:分支选择
类似 Java 的 switch:
xml
<select id="findUsers" resultType="User">
SELECT * FROM users
<where>
<choose>
<when test="searchType == 'name'">
AND name LIKE CONCAT('%', #{keyword}, '%')
</when>
<when test="searchType == 'email'">
AND email = #{keyword}
</when>
<otherwise>
AND id = #{keyword}
</otherwise>
</choose>
</where>
</select>
16.6 <sql>:SQL 片段复用
xml
<sql id="userColumns">
id, name, email, age, status, created_at
</sql>
<select id="findById" resultType="User">
SELECT <include refid="userColumns"/> FROM users WHERE id = #{id}
</select>
<select id="findAll" resultType="User">
SELECT <include refid="userColumns"/> FROM users
</select>
16.7 综合示例:复杂查询
xml
<select id="complexQuery" resultType="User">
SELECT * FROM users
<where>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="ageRange != null">
AND age BETWEEN #{ageRange.min} AND #{ageRange.max}
</if>
<if test="statusList != null and statusList.size() > 0">
AND status IN
<foreach collection="statusList" item="s" open="(" separator="," close=")">
#{s}
</foreach>
</if>
<choose>
<when test="deleted != null">AND deleted = #{deleted}</when>
<otherwise>AND deleted = 0</otherwise>
</choose>
</where>
ORDER BY
<choose>
<when test="sortBy == 'age'">age</when>
<otherwise>id</otherwise>
</choose>
<if test="sortDesc">DESC</if>
</select>
17. 结果映射(ResultMap)与关联查询
17.1 resultType vs resultMap
resultType:自动映射,要求字段名和属性名一致(或开启了驼峰转换)。
xml
<select id="findById" resultType="User">
SELECT id, name, email FROM users WHERE id = #{id}
</select>
resultMap:显式映射,更灵活、可复用、支持关联。
xml
<resultMap id="userMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<result property="email" column="user_email"/>
</resultMap>
<select id="findById" resultMap="userMap">
SELECT user_id, user_name, user_email FROM users WHERE user_id = #{id}
</select>
17.2 一对一关联(association)
场景:每个用户有一个所属部门。
java
public class User {
private Long id;
private String name;
private Department department; // 关联对象
}
public class Department {
private Long id;
private String name;
}
方式 1:嵌套查询(N+1 问题风险)
xml
<resultMap id="userMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<association property="department" column="dept_id"
select="com.example.DepartmentMapper.findById"/>
</resultMap>
<select id="findById" resultMap="userMap">
SELECT * FROM users WHERE id = #{id}
</select>
每查一个 user 都会单独查一次 department,N 个用户就有 N+1 次查询,性能差。
方式 2:嵌套结果(推荐)
xml
<resultMap id="userMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<association property="department" javaType="Department">
<id property="id" column="dept_id"/>
<result property="name" column="dept_name"/>
</association>
</resultMap>
<select id="findById" resultMap="userMap">
SELECT u.id AS user_id, u.name AS user_name,
d.id AS dept_id, d.name AS dept_name
FROM users u
LEFT JOIN departments d ON u.dept_id = d.id
WHERE u.id = #{id}
</select>
一次 JOIN 查出所有数据,性能好。
17.3 一对多关联(collection)
场景:每个用户有多个订单。
java
public class User {
private Long id;
private String name;
private List<Order> orders;
}
xml
<resultMap id="userWithOrders" type="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="amount" column="order_amount"/>
</collection>
</resultMap>
<select id="findUserWithOrders" resultMap="userWithOrders">
SELECT u.id AS user_id, u.name AS user_name,
o.id AS order_id, o.amount AS order_amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = #{id}
</select>
17.4 N+1 问题
问题:用嵌套查询时,列表查询会触发大量子查询。
sql
SELECT * FROM users LIMIT 10 -- 1 次
for each user:
SELECT * FROM departments WHERE id = ? -- 10 次
解决方案:
- 用嵌套结果(JOIN 一次查完)
- 用
fetchType="lazy"延迟加载(用到才查) - 用 IN 批量查询
18. 一级缓存与二级缓存
18.1 一级缓存(SqlSession 级别)
默认开启。同一个 SqlSession 内,相同的查询会被缓存:
java
// 一次会话内
User u1 = userMapper.findById(1L); // 查 DB
User u2 = userMapper.findById(1L); // 走缓存,u1 == u2
注意:在 Spring 中,每次 Mapper 方法调用通常对应一次新的 SqlSession(除非在事务中),所以一级缓存基本只在事务内有效。
18.2 二级缓存(Mapper 级别)
跨 SqlSession 共享,需要手动开启:
xml
<mapper namespace="com.example.UserMapper">
<cache/>
<!-- ... -->
</mapper>
实际项目中很少用 MyBatis 的二级缓存,主要原因:
- 多表关联时缓存难失效
- 多实例部署时缓存不一致
- 大家更倾向于用 Redis 等专门的缓存
19. MyBatis-Plus:开发效率倍增器
MyBatis-Plus 是 MyBatis 的增强工具,国内最流行的 ORM 选择之一。
19.1 主要特性
- 内置通用 CRUD
- 条件构造器(链式查询)
- 自动分页
- 主键策略
- 乐观锁
- 逻辑删除
- 性能分析
19.2 快速上手
1. 引入依赖:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
2. 实体类:
java
@Data
@TableName("users")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String email;
private Integer age;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableLogic // 逻辑删除
private Integer deleted;
}
3. Mapper 继承 BaseMapper:
java
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 不写任何方法,就拥有了基本 CRUD
}
4. Service 继承 ServiceImpl(可选):
java
public interface UserService extends IService<User> { }
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }
19.3 内置 CRUD 方法
java
// 查询
User user = userMapper.selectById(1L);
List<User> users = userMapper.selectList(null);
User one = userMapper.selectOne(new QueryWrapper<User>().eq("email", "a@a.com"));
Long count = userMapper.selectCount(null);
// 插入
userMapper.insert(new User(...));
// 更新
userMapper.updateById(user);
// 删除
userMapper.deleteById(1L);
userMapper.deleteBatchIds(Arrays.asList(1L, 2L, 3L));
19.4 条件构造器(核心)
java
// 等值查询
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("name", "Alice")
.gt("age", 18)
.like("email", "@example.com")
.orderByDesc("created_at");
List<User> users = userMapper.selectList(wrapper);
// Lambda 写法(推荐:列名编译期校验)
LambdaQueryWrapper<User> lambda = new LambdaQueryWrapper<>();
lambda.eq(User::getName, "Alice")
.gt(User::getAge, 18);
List<User> users2 = userMapper.selectList(lambda);
// 链式调用
List<User> result = new LambdaQueryChainWrapper<>(userMapper)
.eq(User::getStatus, "ACTIVE")
.like(User::getName, "李")
.list();
19.5 分页查询
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
// 使用
Page<User> page = new Page<>(1, 10); // 第 1 页,每页 10 条
Page<User> result = userMapper.selectPage(page,
new LambdaQueryWrapper<User>().eq(User::getStatus, "ACTIVE"));
System.out.println(result.getRecords()); // 当前页数据
System.out.println(result.getTotal()); // 总条数
System.out.println(result.getPages()); // 总页数
20. 分页插件 PageHelper
不用 MyBatis-Plus 时,PageHelper 是经典选择:
java
PageHelper.startPage(1, 10); // 必须紧贴查询
List<User> users = userMapper.findAll();
PageInfo<User> page = new PageInfo<>(users);
System.out.println(page.getTotal()); // 总条数
System.out.println(page.getList()); // 当前页数据
注意:PageHelper.startPage() 后紧接着的那次查询会被分页拦截,所以不要在两者之间插入其他查询。
21. MyBatis 常见坑
21.1 #{} 写成 ${} 引发 SQL 注入
xml
<!-- 危险!如果 name 是 "1' OR '1'='1",整张表就被查出来了 -->
SELECT * FROM users WHERE name = '${name}'
21.2 字段名与属性名不一致没映射
数据库 user_name,Java userName。如果没开驼峰转换,会拿不到值:
yaml
mybatis:
configuration:
map-underscore-to-camel-case: true # 一定要开启!
21.3 Boolean 字段在 MyBatis 中的问题
java
private Boolean active;
某些数据库(如 MySQL)的 tinyint(1) 不能自动映射到 Boolean。需要在 XML 中显式声明:
xml
<result property="active" column="active" javaType="boolean" jdbcType="TINYINT"/>
21.4 <foreach> 集合为空
xml
WHERE id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
如果 list 是空集合,会生成 WHERE id IN (),语法错误。要先判空:
xml
<if test="list != null and !list.isEmpty()">
WHERE id IN
<foreach ...>...</foreach>
</if>
21.5 MyBatis 拼接出的 SQL 看不到
加日志配置:
yaml
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
或用 p6spy 等工具显示完整 SQL(含参数值)。
第三部分:注解机制深入
22. 注解的本质是什么
22.1 注解 = 元数据
注解本质上是附加在代码上的元数据。它不直接影响代码逻辑,但可以被工具(编译器、框架)读取并据此执行操作。
类比:
- 商品上的"产地:中国"标签------不影响商品功能,但海关会根据它做处理
- TypeScript 的装饰器
@Component------类还是那个类,但 Angular 框架会按"组件"的方式处理它
java
@Service // 元数据:告诉 Spring "这是个 Service"
@Transactional // 元数据:告诉 Spring "这个类的方法要加事务"
public class UserService {
public void doSomething() { ... }
}
注解本身没有任何行为 ------所有"魔法"都来自读取注解的代码 。Spring 之所以能"理解" @Service,是因为 Spring 启动时会扫描所有类,看哪些类有 @Service 注解,然后做相应处理。
22.2 编译后的注解长什么样
注解在编译后会以特定形式存在 .class 文件中(取决于保留策略)。在运行时通过反射可以读出来。
23. 自定义注解的语法
java
public @interface MyAnnotation {
// 这里定义"属性"
}
注意是 @interface,不是 interface。
23.1 基本属性
java
public @interface LogExecutionTime {
String value() default ""; // 属性 + 默认值
boolean enabled() default true;
int threshold() default 1000;
String[] tags() default {}; // 数组属性
}
// 使用
@LogExecutionTime(value = "查询用户", threshold = 500)
public User findById(Long id) { ... }
// 当只有一个 value 属性时,可以省略名字
@LogExecutionTime("查询用户")
public User findById(Long id) { ... }
// 没有属性时,括号也可省略
@LogExecutionTime
public User findById(Long id) { ... }
23.2 属性可以的类型
注解属性只能是这些类型:
- 基本类型(int、boolean 等)
- String
- Class(如
Class<?> handler()) - 枚举
- 注解
- 以上类型的数组
不能是 Object、自定义类等。
23.3 完整的自定义注解例子
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 每秒允许的请求数
*/
int permitsPerSecond() default 10;
/**
* 限流策略
*/
Strategy strategy() default Strategy.FAIL_FAST;
/**
* 提示信息
*/
String message() default "请求过于频繁";
enum Strategy {
FAIL_FAST, // 直接拒绝
WAIT, // 等待
}
}
24. 元注解:注解的注解
定义注解时,需要在注解上加一些"元注解"来描述这个注解本身的特性。
24.1 @Target:注解能用在哪里
java
@Target(ElementType.METHOD) // 只能用在方法上
@Target({ElementType.TYPE, ElementType.METHOD}) // 类或方法上
ElementType 的可选值:
| 值 | 含义 |
|---|---|
TYPE |
类、接口、枚举 |
METHOD |
方法 |
FIELD |
字段 |
PARAMETER |
方法参数 |
CONSTRUCTOR |
构造器 |
LOCAL_VARIABLE |
局部变量 |
ANNOTATION_TYPE |
注解 |
PACKAGE |
包 |
TYPE_PARAMETER |
泛型参数 |
TYPE_USE |
任何类型使用处 |
24.2 @Retention:注解保留到什么时候
java
@Retention(RetentionPolicy.RUNTIME)
三种策略(下一节详讲):
SOURCE:源码阶段CLASS:编译后保留在 class 文件,但运行时不可见RUNTIME:运行时可见(最常用)
24.3 @Documented:生成 Javadoc 时包含
java
@Documented
public @interface MyAnnotation { }
加了 @Documented 后,使用了这个注解的类在生成 Javadoc 时,注解信息会出现在文档里。
24.4 @Inherited:子类继承
java
@Inherited
public @interface MyAnnotation { }
@MyAnnotation
public class Parent { }
public class Child extends Parent { } // Child 也"拥有" @MyAnnotation
注意:只对类的继承有效,对接口、方法无效。
24.5 @Repeatable:可重复
允许同一个位置加多个相同注解(Java 8+):
java
@Repeatable(Schedules.class)
public @interface Schedule {
String cron();
}
public @interface Schedules {
Schedule[] value();
}
// 使用
@Schedule(cron = "0 0 1 * * ?")
@Schedule(cron = "0 0 13 * * ?")
public void task() { }
25. 注解的三种保留策略
| 策略 | 何时丢弃 | 何时可读 | 典型例子 |
|---|---|---|---|
SOURCE |
编译时 | 编译期工具 | @Override、@SuppressWarnings |
CLASS(默认) |
类加载时 | 字节码工具 | Lombok |
RUNTIME |
永不 | 运行时反射 | Spring 所有注解 |
25.1 SOURCE:源码级注解
只存在于源代码中,编译后就消失:
java
@Override // 编译时检查是否真的重写了父类方法
public String toString() { ... }
25.2 CLASS:编译后存在但运行时不可见
存在于 .class 文件中,但 JVM 加载类时丢弃。Lombok 是典型例子:
java
@Data // Lombok 在编译期处理,生成 getter/setter,运行时这个注解就消失了
public class User {
private String name;
}
25.3 RUNTIME:运行时可见
最常用,可以通过反射读取:
java
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation { }
Spring 的所有注解都是 RUNTIME,因为 Spring 需要在运行时扫描和处理它们。
26. 运行时如何读取注解:反射
26.1 反射基础
反射允许程序在运行时获取类的信息,包括它的注解。
java
Class<?> clazz = UserService.class;
// 获取类上的注解
Service annotation = clazz.getAnnotation(Service.class);
boolean hasService = clazz.isAnnotationPresent(Service.class);
Annotation[] all = clazz.getAnnotations();
// 获取方法上的注解
Method method = clazz.getMethod("findById", Long.class);
Transactional tx = method.getAnnotation(Transactional.class);
// 获取字段上的注解
Field field = clazz.getDeclaredField("userRepository");
Autowired wired = field.getAnnotation(Autowired.class);
// 获取参数上的注解
Parameter param = method.getParameters()[0];
PathVariable pv = param.getAnnotation(PathVariable.class);
26.2 完整的"读取注解"例子
java
// 1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
String value() default "";
}
// 2. 使用注解
public class UserService {
@Loggable("查询用户")
public User findById(Long id) { ... }
public User findByEmail(String email) { ... }
}
// 3. 读取注解
public class AnnotationReader {
public static void main(String[] args) throws Exception {
for (Method method : UserService.class.getDeclaredMethods()) {
if (method.isAnnotationPresent(Loggable.class)) {
Loggable loggable = method.getAnnotation(Loggable.class);
System.out.println(method.getName() + " 被标记: " + loggable.value());
} else {
System.out.println(method.getName() + " 未被标记");
}
}
}
}
// 输出:
// findById 被标记: 查询用户
// findByEmail 未被标记
26.3 反射的性能成本
反射比直接调用慢很多。但 Spring 通常在启动时用反射做扫描和初始化,运行期则尽量用缓存或字节码生成(CGLIB)来避免反射开销。
27. Spring 是怎么"理解"注解的
27.1 启动阶段做了什么
Spring Boot 启动时大致流程:
java
1. 启动类的 @SpringBootApplication 触发
↓
2. @ComponentScan 扫描包下所有类
↓
3. 用反射检查每个类是否有 @Component、@Service、@Repository、@Controller 等
↓
4. 找到了 → 创建 BeanDefinition → 注册到容器
↓
5. 实例化 Bean 时,检查字段/方法/参数上的 @Autowired、@Value 等
↓
6. 检查类/方法上的 @Transactional、@Cacheable、@Async 等
↓
7. 为这些 Bean 生成 AOP 代理
27.2 一个具体例子:@Transactional 的处理
swift
启动时:
Spring 扫描 UserService → 发现 @Service → 创建 BeanDefinition
↓
BeanPostProcessor 中的 InfrastructureAdvisorAutoProxyCreator 检查
↓
发现 UserService 有 @Transactional 注解(在方法或类上)
↓
为 UserService 创建 CGLIB 代理,把 TransactionInterceptor 织入
调用时:
外部调用 userService.createOrder() 实际调到代理
↓
代理执行 TransactionInterceptor.invoke()
↓
开启事务 → 调原方法 → 提交/回滚事务
27.3 注解只是"约定"
理解关键:注解本身什么都不做 。是 Spring 的处理器(BeanPostProcessor、InfrastructureAdvisorAutoProxyCreator 等)读到这些注解后才执行对应逻辑。
如果你拿着一个有 @Transactional 注解的 POJO 自己 new 一个出来调用,事务根本不会生效。
28. 组合注解(Composed Annotation)
28.1 什么是组合注解
把多个注解的功能合并成一个新注解。
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Controller // ← 元注解:它本身有 @Controller
@ResponseBody // ← 元注解:它本身有 @ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}
这就是为什么 @RestController = @Controller + @ResponseBody------它的定义里就包含这两个。
28.2 @SpringBootApplication 的真面目
java
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(...)
public @interface SpringBootApplication { ... }
一个注解搞定三件事:配置类标记、开启自动配置、扫描组件。这就是组合注解的威力。
28.3 @AliasFor:属性别名
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
@AliasFor(annotation = RequestMapping.class)
String[] value() default {};
@AliasFor(annotation = RequestMapping.class)
String[] path() default {};
}
@GetMapping("/users") 实际是 @RequestMapping(method = GET, value = "/users") 的语法糖。
28.4 自定义组合注解
java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class) // 包含事务
@Slf4j // 包含日志
public @interface BusinessOperation {
String value() default "";
}
// 使用
@BusinessOperation("创建订单")
public void createOrder() { ... }
// 自动具备:事务管理 + 日志能力
29. 实战:自定义业务注解的几个例子
29.1 接口幂等性注解
防止用户重复提交订单:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
int expireSeconds() default 5; // 防重时间窗口
String key() default ""; // 唯一键的 SpEL 表达式
}
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final StringRedisTemplate redis;
@Around("@annotation(idempotent)")
public Object check(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
String key = buildKey(pjp, idempotent);
Boolean ok = redis.opsForValue().setIfAbsent(key, "1",
Duration.ofSeconds(idempotent.expireSeconds()));
if (!Boolean.TRUE.equals(ok)) {
throw new BusinessException("请勿重复提交");
}
return pjp.proceed();
}
private String buildKey(ProceedingJoinPoint pjp, Idempotent idempotent) {
// 基于方法签名 + 参数生成唯一 key
return "idem:" + pjp.getSignature().toShortString()
+ ":" + Arrays.hashCode(pjp.getArgs());
}
}
// 使用
@Idempotent(expireSeconds = 3)
@PostMapping("/orders")
public Order createOrder(@RequestBody OrderRequest req) { ... }
29.2 操作日志注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String module(); // 模块
String action(); // 操作
}
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private final OperationLogService logService;
@AfterReturning(value = "@annotation(opLog)", returning = "result")
public void log(JoinPoint jp, OperationLog opLog, Object result) {
OperationLogRecord record = new OperationLogRecord();
record.setModule(opLog.module());
record.setAction(opLog.action());
record.setMethod(jp.getSignature().toShortString());
record.setArgs(JSON.toJSONString(jp.getArgs()));
record.setResult(JSON.toJSONString(result));
record.setOperator(getCurrentUserId());
record.setTime(LocalDateTime.now());
logService.save(record);
}
}
// 使用
@OperationLog(module = "用户", action = "删除")
@DeleteMapping("/users/{id}")
public void delete(@PathVariable Long id) { ... }
29.3 数据脱敏注解
java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveSerializer.class)
public @interface Sensitive {
SensitiveType type();
enum SensitiveType {
PHONE, ID_CARD, EMAIL, NAME
}
}
public class SensitiveSerializer extends JsonSerializer<String> implements ContextualSerializer {
private Sensitive.SensitiveType type;
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
String masked = switch (type) {
case PHONE -> value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
case EMAIL -> value.replaceAll("(.{1,3}).*?(@.+)", "$1***$2");
case NAME -> value.charAt(0) + "**";
case ID_CARD -> value.replaceAll("(\\d{6})\\d{8}(\\d{4})", "$1********$2");
};
gen.writeString(masked);
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
Sensitive ann = property.getAnnotation(Sensitive.class);
SensitiveSerializer s = new SensitiveSerializer();
s.type = ann.type();
return s;
}
}
// 使用
public class UserVO {
private String name;
@Sensitive(type = Sensitive.SensitiveType.PHONE)
private String phone; // 13812345678 → 138****5678
@Sensitive(type = Sensitive.SensitiveType.EMAIL)
private String email;
}
29.4 权限校验注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value(); // 必须拥有的角色(任意一个)
}
@Aspect
@Component
public class RoleCheckAspect {
@Before("@annotation(requireRole)")
public void check(RequireRole requireRole) {
User user = SecurityContext.getCurrentUser();
if (user == null) throw new BusinessException("未登录");
Set<String> userRoles = user.getRoles();
boolean has = Arrays.stream(requireRole.value()).anyMatch(userRoles::contains);
if (!has) throw new BusinessException("无权限");
}
}
// 使用
@RequireRole({"ADMIN", "SUPER_ADMIN"})
@DeleteMapping("/users/{id}")
public void deleteUser(@PathVariable Long id) { ... }
30. 注解 vs XML vs 编程式配置
30.1 三种配置方式对比
| 方式 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|
| 注解 | 简洁、与代码贴近、IDE 友好 | 修改要重新编译、分散在各处 | 大部分场景 |
| XML | 集中管理、不需重新编译就能改 | 啰嗦、易出错 | 老项目、需要热更新配置 |
| 编程式(Java Config) | 灵活、有完整 Java 能力 | 较啰嗦 | 复杂条件配置、第三方类配置 |
30.2 实际项目的选择
现代 Spring Boot 项目的典型组合:
- 业务代码 :100% 注解(
@Service、@Controller等) - 复杂配置 :Java Config(
@Configuration+@Bean) - 环境相关配置 :
application.yml - 几乎不用 XML
30.3 注解滥用的代价
注解是好东西,但也别用过头:
java
// 反例:注解地狱
@Service
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
@Slf4j
@Validated
@CacheConfig(cacheNames = "users")
@Async
@PreAuthorize("hasRole('USER')")
@RateLimit(permitsPerSecond = 10)
@OperationLog(module = "用户", action = "操作")
@RequiredArgsConstructor
public class UserService { ... }
每个注解都有用,但堆在一起会让人难以读懂。
建议:
- 一个类不要超过 5 个注解,超了就考虑拆分或用组合注解
- 自定义注解要有清晰的命名和文档
- 不是所有横切逻辑都适合 AOP+注解,简单的就直接写
总结:三大主题的内在联系
回顾这三个主题,会发现它们其实是一体的:
perl
注解(声明意图)
↓
被 Spring/MyBatis 读取
↓
┌───────────────┼───────────────┐
↓ ↓
Spring MVC MyBatis
(HTTP 请求处理) (数据库交互)
↓ ↓
@RestController @Mapper
@RequestMapping @Select / @Insert / ...
@RequestBody #{} 参数绑定
@ExceptionHandler ResultMap
↓ ↓
└───────────────┬───────────────┘
↓
反射 + 代理 + 容器管理
↓
完整的应用运行
核心思路:
- 注解是声明,"我要做什么"
- Spring MVC 处理 HTTP 层的注解(路由、参数、返回值)
- MyBatis 处理数据层的注解(SQL 映射)
- 框架在背后用反射和代理把这些声明翻译成实际行为
理解到这一步,你已经能看穿 Spring 大多数"魔法"了。后续学 Spring Security、Spring Cloud 等,都是这套模式的延伸:用注解描述意图 + 框架在容器层做处理。
学习建议
- MVC 部分 :建议自己搭一个 Spring Boot 项目,从一个简单的
Hello World开始,逐步加@RequestBody、@Valid、@ExceptionHandler,打开 DEBUG 日志看请求处理流程。 - MyBatis 部分:先用原生 MyBatis 写一遍 CRUD 加复杂动态 SQL,再切换到 MyBatis-Plus 感受效率提升。
- 注解部分 :自己实现一个
@LogExecutionTime注解,跑通"定义注解 → AOP 切面 → 业务方法使用"的完整链路。
写得越多,理解越深。祝学习顺利!🚀