Spring 深入篇:MVC 请求处理 / MyBatis / 注解机制

Spring 深入篇:MVC 请求处理 / MyBatis / 注解机制

本文是 Spring 学习文档的第三篇深入篇,聚焦三个最高频的实战主题:Spring MVC 请求处理全流程、MyBatis 数据访问、Java 注解机制。理解这三个,开发体验会有质的提升。


目录

第一部分:Spring MVC 请求处理流程深入

  1. 从一次浏览器请求说起
  2. DispatcherServlet:请求的总入口
  3. 完整的请求处理九步流程
  4. HandlerMapping:路由如何匹配
  5. HandlerAdapter:方法如何被调用
  6. [参数解析:@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")
  7. [返回值处理:对象如何变成 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")
  8. [拦截器(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")
  9. 全局异常处理深入
  10. 文件上传、跨域、统一响应
  11. [MVC 常见问题排查](#MVC 常见问题排查 "#11-mvc-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5")

第二部分:MyBatis 深入

  1. [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")
  2. [MyBatis 核心组件](#MyBatis 核心组件 "#13-mybatis-%E6%A0%B8%E5%BF%83%E7%BB%84%E4%BB%B6")
  3. [XML Mapper 完整写法](#XML Mapper 完整写法 "#14-xml-mapper-%E5%AE%8C%E6%95%B4%E5%86%99%E6%B3%95")
  4. [注解 Mapper 写法](#注解 Mapper 写法 "#15-%E6%B3%A8%E8%A7%A3-mapper-%E5%86%99%E6%B3%95")
  5. [动态 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")
  6. 结果映射(ResultMap)与关联查询
  7. 一级缓存与二级缓存
  8. MyBatis-Plus:开发效率倍增器
  9. [分页插件 PageHelper](#分页插件 PageHelper "#20-%E5%88%86%E9%A1%B5%E6%8F%92%E4%BB%B6-pagehelper")
  10. [MyBatis 常见坑](#MyBatis 常见坑 "#21-mybatis-%E5%B8%B8%E8%A7%81%E5%9D%91")

第三部分:注解机制深入

  1. 注解的本质是什么
  2. 自定义注解的语法
  3. 元注解:注解的注解
  4. 注解的三种保留策略
  5. 运行时如何读取注解:反射
  6. [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")
  7. [组合注解(Composed Annotation)](#组合注解(Composed Annotation) "#28-%E7%BB%84%E5%90%88%E6%B3%A8%E8%A7%A3composed-annotation")
  8. 实战:自定义业务注解的几个例子
  9. [注解 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 启动时,自动配置完成了这些事:

  1. 启动内嵌 Tomcat
  2. 注册 DispatcherServlet,映射到 /(接管所有请求)
  3. 初始化九大组件(下面会讲)
  4. 扫描所有 @Controller@RestController,注册到路由表

2.3 DispatcherServlet 的九大组件

组件 作用
HandlerMapping 找到处理请求的 Controller 方法
HandlerAdapter 实际调用 Controller 方法
HandlerExceptionResolver 处理异常
ViewResolver 解析视图(前后端分离时基本不用)
LocaleResolver 国际化解析
ThemeResolver 主题解析
MultipartResolver 文件上传解析
RequestToViewNameTranslator 默认视图名生成
FlashMapManager Flash 属性管理(重定向数据)

重点关注HandlerMappingHandlerAdapterHandlerExceptionResolverMultipartResolver


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 方法。它做了三件事:

  1. 解析方法参数 (用 HandlerMethodArgumentResolver
  2. 反射调用方法
  3. 处理返回值 (用 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);

生成的 SQLSELECT * 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 次

解决方案:

  1. 用嵌套结果(JOIN 一次查完)
  2. fetchType="lazy" 延迟加载(用到才查)
  3. 用 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 的处理器(BeanPostProcessorInfrastructureAdvisorAutoProxyCreator 等)读到这些注解后才执行对应逻辑。

如果你拿着一个有 @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 等,都是这套模式的延伸:用注解描述意图 + 框架在容器层做处理


学习建议

  1. MVC 部分 :建议自己搭一个 Spring Boot 项目,从一个简单的 Hello World 开始,逐步加 @RequestBody@Valid@ExceptionHandler,打开 DEBUG 日志看请求处理流程。
  2. MyBatis 部分:先用原生 MyBatis 写一遍 CRUD 加复杂动态 SQL,再切换到 MyBatis-Plus 感受效率提升。
  3. 注解部分 :自己实现一个 @LogExecutionTime 注解,跑通"定义注解 → AOP 切面 → 业务方法使用"的完整链路。

写得越多,理解越深。祝学习顺利!🚀

相关推荐
敖正炀3 小时前
云原生持续交付:GitOps 与渐进式发布
分布式·架构
xwz小王子3 小时前
SkiP:让模仿学习学会“快进“——动作重标记如何在不改架构的情况下削减机器人 15-40% 的执行步数
学习·架构·机器人
靠谱品牌推荐官3 小时前
【架构实战】如何设计一套原生支持 GEO 大模型爬虫语义索引的 HTML5/CSS3 纯净白盒前端架构?
前端·爬虫·架构
枫叶林FYL3 小时前
【自然语言处理 NLP】9.1 检索增强生成高级架构:GraphRAG 与结构化知识检索
人工智能·自然语言处理·架构
heimeiyingwang4 小时前
【架构实战】分布式ID生成:雪花算法与业务ID设计
分布式·算法·架构
oo哦哦4 小时前
矩阵运营的智能风控体系:2026年平台规则下的合规技术架构
人工智能·矩阵·架构
high20114 小时前
【架构】-- Mysql delete vs truncate 深度解析
数据库·mysql·架构
2601_957787584 小时前
AI数字人驱动的矩阵内容生产:2026年技术架构与人效革命
人工智能·矩阵·架构
上海云盾第一敬业销售4 小时前
DDoS防护解决方案架构解析:保障网站安全的新利器
安全·架构·ddos