spring mvc 核心快速入门,鸿蒙开发必学后端基础

SpringMVC 核心技术与简易快速入门,鸿蒙后端必备知识点专题

鸿蒙云开发前, 我们需要了解一下后端基础,因为实际开发的时候我们用ai, 但是基础理伦要学会的,所以这是为后端朋友准备的一门快速入门课程
一份可以直接照着敲、照着跑的 SpringMVC 学习笔记。 每个技术点都按「核心知识讲解 → 完整示例代码 → 测试验证方法 → 常见坑」组织。 环境:JDK 8+、SpringBoot 2.7.x(javax 包)、Maven。文末附 SpringBoot 3.x 差异说明。

目录

  • 第 0 章 准备工作:5 分钟搭好可运行工程
  • 第 1 章 SpringMVC 是什么:核心组件与请求处理流程
  • 第 2 章 请求映射:@RequestMapping 家族
  • 第 3 章 请求参数绑定:7 种接收方式全讲透
  • 第 4 章 响应处理:视图、JSON 与统一返回体
  • 第 5 章 RESTful 风格接口:完整 CRUD 实战
  • 第 6 章 参数校验:JSR-303 声明式校验
  • 第 7 章 异常处理:局部与全局方案
  • 第 8 章 拦截器:登录鉴权实战
  • 第 9 章 文件上传与下载
  • 第 10 章 高频补充:跨域、转发与重定向、Servlet API
  • 第 11 章 综合实战:商品管理模块完整代码
  • 第 12 章 常见错误排查清单

第 0 章 准备工作:5 分钟搭好可运行工程

本教程所有案例都基于同一个 SpringBoot 工程,先把骨架搭好,后面每一章的代码直接往里面加类即可。

0.1 创建工程

方式任选其一:
  • 在线生成:打开 https://start.spring.io/,选择 Maven + Java 8/11/17 + SpringBoot 2.7.18,依赖勾选 Spring Web,下载解压后用 IDEA 打开。
  • IDEA 创建:New Project → Spring Initializr,同样勾选 Spring Web。

0.2 pom.xml 关键依赖

xml
 
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
</parent>

<dependencies>
    <!-- SpringMVC 核心:包含 spring-webmvc + 内嵌 Tomcat + Jackson -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 第 6 章参数校验用 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- 简化 getter/setter,可选但强烈推荐 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>
spring-boot-starter-web 一个依赖就替我们做了三件事:内嵌 Tomcat(不用装服务器)、自动注册 DispatcherServlet(不用写 web.xml)、内置 Jackson(返回对象自动转 JSON)。

0.3 启动类与工程结构

java
 
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
推荐包结构(Controller 必须放在启动类同级或子包下,否则扫描不到):
plain
 
com.example.demo
├── DemoApplication.java        ← 启动类
├── controller                  ← 控制层(本教程代码都放这里)
│   ├── HelloController.java
│   └── ...
├── entity / dto                ← 实体与传输对象
├── config                      ← 配置类(拦截器注册等)
└── exception                   ← 异常相关

0.4 验证环境

启动 DemoApplication,控制台看到 Tomcat started on port(s): 8080 即成功。后面每章都有对应的访问测试,跟着做即可。

第 1 章 SpringMVC 是什么:核心组件与请求处理流程

1.1 核心知识点

SpringMVC 是 Spring 家族中负责 Web 层的 MVC 框架,它把「接收请求 → 调用业务 → 组织响应」这件事标准化了。
传统 Servlet 开发的痛点,正是 SpringMVC 要解决的问题:
表格
 
 
传统 Servlet 痛点SpringMVC 的对策
一个功能写一个 Servlet,web.xml 越配越多 一个 @Controller 类可以承载任意多个接口方法
request.getParameter 拿到的全是字符串,类型转换、对象封装都要手写 参数自动绑定、自动类型转换、自动封装成 Java 对象
跳转代码重复,视图名散落各处 视图解析器统一拼前缀后缀
返回 JSON 要手动序列化、处理乱码 @ResponseBody 自动转 JSON,默认 UTF-8

1.2 五大核心组件(面试必问)

一次请求从进来到出去,由这五个角色接力完成:
  1. DispatcherServlet(前端控制器):唯一入口,所有请求先到这里,由它统一调度。本质是一个 Servlet。
  2. HandlerMapping(处理器映射器):根据请求 URL,找到应该执行哪个 Controller 的哪个方法。
  3. HandlerAdapter(处理器适配器):以统一方式调用处理方法,并完成参数绑定。
  4. Handler(处理器):就是我们写的 @Controller 里的方法——五大组件中唯一需要程序员编写的。
  5. ViewResolver(视图解析器):把逻辑视图名(如 user/list)解析成真实页面路径。前后端分离项目中退居幕后。

1.3 一次请求的完整流程(8 步)

plain
 
浏览器 → ① DispatcherServlet 接收请求
       → ② 问 HandlerMapping:这个 URL 该谁处理?
       → ③ 找到 Handler(你的 Controller 方法)
       → ④ HandlerAdapter 调用方法(先过拦截器 preHandle)
       → ⑤ 方法执行,返回 ModelAndView / 对象
       → ⑥(拦截器 postHandle)ViewResolver 解析视图名
       → ⑦ 视图渲染 / Jackson 序列化为 JSON
       → ⑧ 响应写回浏览器(拦截器 afterCompletion)
记住一句话:DispatcherServlet 是总调度,HandlerMapping 负责找,HandlerAdapter 负责调,ViewResolver 负责拼页面。

1.4 示例:第一个接口

在 controller 包下新建:
java
 
package com.example.demo.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class HelloController {

    @RequestMapping("/hello")
    @ResponseBody                     // 返回值直接作为响应体,不跳转页面
    public String hello() {
        return "Hello SpringMVC!";
    }
}
测试:重启工程,浏览器访问 http://localhost:8080/hello,页面显示 Hello SpringMVC!。
知识点:@Controller 把类交给 Spring 容器管理;@RequestMapping("/hello") 建立 URL 与方法的映射;@ResponseBody 告诉框架返回值不是视图名,而是直接写给浏览器的响应内容。
常见坑:类上忘了加 @Controller,或 Controller 不在启动类扫描范围内 → 访问报 404。

第 2 章 请求映射:@RequestMapping 家族

2.1 核心知识点

@RequestMapping 负责建立「URL + 请求方式 → Java 方法」的映射关系,可写在类上(窄化路径,相当于统一前缀)和方法上。
表格
 
 
属性作用示例
value / path 映射路径(两者等价) @RequestMapping("/user")
method 限定请求方式 method = RequestMethod.POST
params 必须携带/不能携带某参数 params = "id"、params = "!id"
headers 必须携带某请求头 headers = "token"
consumes 限定的请求内容类型 consumes = "application/json"
produces 限定的响应内容类型 produces = "application/json;charset=utf-8"
实际开发中更常用 派生注解(语义更明确,推荐):
表格
 
 
派生注解等价写法用途
@GetMapping method = GET 查询
@PostMapping method = POST 新增
@PutMapping method = PUT 全量修改
@DeleteMapping method = DELETE 删除
路径还支持 Ant 风格通配符:? 匹配单个字符,* 匹配一层任意字符,** 匹配任意多层路径。

2.2 示例代码

java
 
package com.example.demo.controller;

import org.springframework.web.bind.annotation.*;

@RestController                 // = @Controller + @ResponseBody,本类全部返回数据
@RequestMapping("/map")         // 类上统一前缀:本类所有接口都在 /map 下
public class MappingController {

    // 1. 最简映射:GET /map/a
    @GetMapping("/a")
    public String a() {
        return "simple get mapping";
    }

    // 2. 限定 POST:POST /map/b
    @PostMapping("/b")
    public String b() {
        return "only post";
    }

    // 3. 一个方法映射多个路径:/map/c 或 /map/cc 都能访问
    @GetMapping({"/c", "/cc"})
    public String c() {
        return "multi path";
    }

    // 4. 必须带参数 id 才匹配:GET /map/d?id=1 成功,不带则 400
    @GetMapping(value = "/d", params = "id")
    public String d(Long id) {
        return "id = " + id;
    }

    // 5. Ant 通配符:GET /map/e/x/y/z 可匹配
    @GetMapping("/e/**")
    public String e() {
        return "ant style ** matched";
    }
}

2.3 测试验证

bash
 
curl http://localhost:8080/map/a
curl -X POST http://localhost:8080/map/b
curl "http://localhost:8080/map/d?id=100"
curl http://localhost:8080/map/d          # 报 400,缺参数 id
curl http://localhost:8080/map/e/a/b/c

2.4 常见坑

  • 同一个 URL + 同一种请求方式映射到两个方法 → 启动直接报错 Ambiguous mapping。
  • 类上写了 @RequestMapping("/api"),访问时忘了加前缀。
  • 用浏览器地址栏测 POST 接口 → 浏览器地址栏只能发 GET,POST 必须用 Postman 或 curl。

第 3 章 请求参数绑定:7 种接收方式全讲透

参数绑定是 SpringMVC 日常开发的核心。客户端传参的方式不同,后端接收方式也不同,本章逐一讲解。

3.1 方式一:简单参数(形参与请求参数同名)

请求参数名与方法形参名一致时,框架自动注入并做类型转换。
java
 
@GetMapping("/param/simple")
public String simple(String name, Integer age) {
    return "name=" + name + ", age=" + age;
}
测试:http://localhost:8080/param/simple?name=tom&age=18
注意:基本类型用包装类(Integer 而非 int),参数缺失时不会报 500,而是得到 null。

3.2 方式二:@RequestParam(参数名不一致 / 必填控制 / 默认值)

当请求参数名与形参名不一致,或需要声明「必填、默认值」时使用。
java
 
@GetMapping("/param/request")
public String requestParam(
        @RequestParam("username") String name,                  // 参数名不一致
        @RequestParam(required = false) Integer age,            // 非必填
        @RequestParam(defaultValue = "1") Integer pageNum) {    // 默认值
    return "name=" + name + ", age=" + age + ", pageNum=" + pageNum;
}
测试:
bash
 
curl "http://localhost:8080/param/request?username=tom"     # age 省略、pageNum 用默认值
curl "http://localhost:8080/param/request"                  # 400,username 必填
@RequestParam 三个属性记牢:value 指定参数名,required 控制必填(默认 true),defaultValue 提供默认值。

3.3 方式三:@PathVariable(路径中的参数)

RESTful 风格把参数放进 URL 路径,如 /user/1001,用 @PathVariable 取出。
java
 
@GetMapping("/user/{id}")
public String pathVariable(@PathVariable("id") Long userId) {
    return "userId = " + userId;
}

// 多个路径参数
@GetMapping("/user/{uid}/order/{oid}")
public String multiPath(@PathVariable Long uid, @PathVariable Long oid) {
    return "uid=" + uid + ", oid=" + oid;
}
测试:http://localhost:8080/user/1001
变量名与形参名一致时,@PathVariable 的 value 可以省略。占位符里的名字必须和 @PathVariable 对应。

3.4 方式四:@RequestHeader 与 @CookieValue

java
 
@GetMapping("/param/header")
public String header(@RequestHeader("User-Agent") String userAgent,
                     @RequestHeader(value = "token", required = false) String token,
                     @CookieValue(value = "JSESSIONID", required = false) String sessionId) {
    return "UA=" + userAgent + ", token=" + token + ", session=" + sessionId;
}
测试:
bash
 
curl -H "token: abc123" http://localhost:8080/param/header

3.5 方式五:POJO 对象绑定(表单提交最常用)

请求参数名与实体类属性名一致时,框架自动封装成对象,不需要任何注解。
先建实体类:
java
 
package com.example.demo.entity;

import lombok.Data;
import java.util.Date;
import java.util.List;

@Data   // Lombok:自动生成 getter/setter/toString
public class User {
    private String username;
    private Integer age;
    private String[] hobbies;          // 同名多值自动收进数组
    private List<String> tags;         // 也可用集合接收
    private Address address;           // 级联属性:address.city

    @Data
    public static class Address {
        private String city;
        private String street;
    }
}
接收方法:
java
 
@PostMapping("/param/pojo")
public String pojo(User user) {
    return user.toString();
}
测试(模拟表单提交):
bash
 
curl -X POST http://localhost:8080/param/pojo \
  -d "username=tom&age=18&hobbies=篮球&hobbies=读书&tags=a&tags=b&address.city=杭州&address.street=西湖区"
要点:hobbies=篮球&hobbies=读书 同名多值自动收进数组;address.city=杭州 用「属性名.子属性名」完成级联封装。这就是为什么实体类必须有 setter(Lombok 的 @Data 已生成)。

3.6 方式六:@RequestBody 接收 JSON(前后端分离最常用)

前端用 Content-Type: application/json 提交 JSON 字符串时,必须用 @RequestBody 接收,框架用 Jackson 自动反序列化为对象。
java
 
@PostMapping("/param/json")
public String json(@RequestBody User user) {
    return user.toString();
}
测试:
bash
 
curl -X POST http://localhost:8080/param/json \
  -H "Content-Type: application/json" \
  -d '{"username":"tom","age":18,"address":{"city":"杭州","street":"西湖区"}}'
最高频的坑:用 JSON 传参却不加 @RequestBody,结果对象属性全是 null。反过来,表单提交加 @RequestBody 会报 415。口诀:表单绑定不用注解,JSON 必加 @RequestBody。

3.7 方式七:日期与集合参数处理

日期字符串默认无法直接绑定到 Date 类型,需要声明格式:
java
 
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;

public class RegisterForm {
    private String username;

    // 表单/URL 传参时的日期格式:birthday=2000-01-01
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    // JSON 传参时的日期格式(两者作用域不同,常一起写)
    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    private Date birthday;

    // getter/setter 省略(可用 @Data)
}
记忆点:@DateTimeFormat 管表单/URL 参数的转换,@JsonFormat 管 JSON 体里的日期,两个注解作用域不同,互不替代。

3.8 小结:一张速查表

表格
 
 
传参方式后端写法典型场景
?name=tom 同名参数 直接写形参 简单查询
参数名不一致/必填/默认值 @RequestParam 分页参数
/user/1001 路径参数 @PathVariable RESTful 查详情
请求头 / Cookie @RequestHeader / @CookieValue Token、UA
表单提交 POJO(无注解) 页面表单
JSON 提交 @RequestBody + POJO 前后端分离
日期字符串 @DateTimeFormat / @JsonFormat 注册生日

第 4 章 响应处理:视图、JSON 与统一返回体

4.1 核心知识点:返回值类型决定响应方式

表格
 
 
返回值框架行为场景
String(无 @ResponseBody) 当作逻辑视图名,跳页面 服务端渲染
ModelAndView 视图名 + 数据一起返回 老项目常见
任意对象 + @ResponseBody Jackson 序列化为 JSON 写出 前后端分离
void 自己操作 response 写出 文件下载
ResponseEntity 自定义状态码 + 响应头 + 响应体 需要精确控制响应
@RestController = @Controller + @ResponseBody,标注后类里所有方法的返回值都直接作为响应数据,不再走视图解析。

4.2 示例一:返回 JSON 数据

java
 
@RestController
@RequestMapping("/resp")
public class ResponseController {

    // 返回对象 → 自动转 JSON
    @GetMapping("/user")
    public User user() {
        User u = new User();
        u.setUsername("tom");
        u.setAge(18);
        return u;
    }

    // 返回集合 → JSON 数组
    @GetMapping("/users")
    public List<User> users() {
        return List.of(user(), user());
    }

    // 返回 Map → JSON 对象
    @GetMapping("/map")
    public Map<String, Object> map() {
        return Map.of("code", 200, "msg", "success");
    }
}
测试:curl http://localhost:8080/resp/user 得到:
JSON
 
{"username":"tom","age":18,"hobbies":null,"tags":null,"address":null}
幕后功臣是 HttpMessageConverter:框架根据请求头 Accept 选择转换器,对象 ↔ JSON 默认由 Jackson 完成。常用微调注解:@JsonIgnore(屏蔽字段,如密码)、@JsonFormat(日期格式)、@JsonInclude(JsonInclude.Include.NON_NULL)(不输出 null 字段)。

4.3 示例二:统一返回体 Result(企业标配)

实际项目中所有接口返回统一结构 {code, msg, data},前端只需一套解析逻辑。这是必须养成的习惯。
java
 
package com.example.demo.entity;

import lombok.Data;

@Data
public class Result<T> {
    private Integer code;   // 0 成功,其他为业务错误码
    private String msg;
    private T data;

    public static <T> Result<T> ok(T data) {
        Result<T> r = new Result<>();
        r.setCode(0);
        r.setMsg("success");
        r.setData(data);
        return r;
    }

    public static <T> Result<T> ok() {
        return ok(null);
    }

    public static <T> Result<T> fail(Integer code, String msg) {
        Result<T> r = new Result<>();
        r.setCode(code);
        r.setMsg(msg);
        return r;
    }
}
使用:
java
 
@GetMapping("/resp/result")
public Result<User> result() {
    return Result.ok(user());
}

4.4 示例三:ResponseEntity 精确控制状态码

java
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

@PostMapping("/resp/entity")
public ResponseEntity<Result<Long>> create() {
    // 新增成功返回 201 Created,body 里放业务数据
    return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(1001L));
}
经验:HTTP 状态码表达「传输层结果」(401 未登录、404 不存在、500 服务异常),业务错误码表达「业务结果」(1001 库存不足、1002 价格变动),两者互补不冲突。

第 5 章 RESTful 风格接口:完整 CRUD 实战

5.1 核心知识点

RESTful 是一种接口设计风格,核心思想:用 URL 定位资源,用 HTTP 方法表达操作。
表格
 
 
操作传统写法RESTful 写法
查询全部 GET /user/list GET /users
查询单个 GET /user/get?id=1 GET /users/1
新增 POST /user/add POST /users
修改 POST /user/update PUT /users/1
删除 GET /user/delete?id=1 DELETE /users/1
要点:接口名里不出现动词(add/delete/update),动作由 HTTP 方法表达;同一资源用同一 URL,靠方法区分操作。

5.2 完整示例:用户资源 CRUD

下面是一个可以直接运行的完整案例,用内存 Map 模拟数据库,重点看接口写法。
java
 
package com.example.demo.controller;

import com.example.demo.entity.Result;
import com.example.demo.entity.User;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/users")
public class UserCrudController {

    // 内存模拟数据库
    private final Map<Long, User> store = new ConcurrentHashMap<>();
    private final AtomicLong idGen = new AtomicLong(1000);

    // 1. 查询全部:GET /users
    @GetMapping
    public Result<List<User>> list() {
        return Result.ok(new ArrayList<>(store.values()));
    }

    // 2. 查询单个:GET /users/1001
    @GetMapping("/{id}")
    public Result<User> get(@PathVariable Long id) {
        User user = store.get(id);
        if (user == null) {
            return Result.fail(404, "用户不存在");
        }
        return Result.ok(user);
    }

    // 3. 新增:POST /users   (JSON 请求体)
    @PostMapping
    public ResponseEntity<Result<Long>> add(@RequestBody User user) {
        long id = idGen.incrementAndGet();
        store.put(id, user);
        // 新增成功返回 201 Created
        return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(id));
    }

    // 4. 修改:PUT /users/1001
    @PutMapping("/{id}")
    public Result<Void> update(@PathVariable Long id, @RequestBody User user) {
        if (!store.containsKey(id)) {
            return Result.fail(404, "用户不存在");
        }
        store.put(id, user);
        return Result.ok();
    }

    // 5. 删除:DELETE /users/1001
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        store.remove(id);
        // 删除成功返回 204 No Content(无响应体)
        return ResponseEntity.noContent().build();
    }
}

5.3 用 Postman 或 curl 逐个测试

bash
 
# 新增
curl -X POST http://localhost:8080/users -H "Content-Type: application/json" \
     -d '{"username":"tom","age":18}'

# 查询全部
curl http://localhost:8080/users

# 查询单个
curl http://localhost:8080/users/1001

# 修改
curl -X PUT http://localhost:8080/users/1001 -H "Content-Type: application/json" \
     -d '{"username":"jerry","age":20}'

# 删除
curl -X DELETE http://localhost:8080/users/1001 -v     # -v 可看到 204 状态码

5.4 常见坑

  • PUT/DELETE 请求用浏览器测不了,必须 Postman/curl。
  • 前端框架(如 axios)发 PUT 带 JSON 时别忘 Content-Type: application/json。
  • 删除返回 204 时没有响应体,前端不要再去解析 data。

第 6 章 参数校验:JSR-303 声明式校验

6.1 核心知识点

手写 if (name == null || name.isEmpty()) 校验既啰嗦又容易漏。JSR-303(Bean Validation)把校验规则声明在字段注解上,框架自动执行。
常用注解速查表:
表格
 
 
注解规则适用类型
@NotNull 不能为 null 任意
@NotBlank 非 null 且去空格后长度 > 0 String
@NotEmpty 非 null 且非空 集合/数组/字符串
@Size(min, max) 长度/元素个数范围 字符串/集合
@Min(n) / @Max(n) 数值上下限 数值
@Email 邮箱格式 String
@Pattern(regexp) 自定义正则 String
@Valid 级联校验嵌套对象 对象属性
@NotNull 挡不住空字符串 ""——字符串字段请用 @NotBlank。

6.2 使用三步走

第一步:在 DTO 字段上声明规则。
java
 
package com.example.demo.dto;

import lombok.Data;
import javax.validation.constraints.*;

@Data
public class RegisterDTO {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @Size(min = 6, max = 20, message = "密码长度必须在 6~20 位之间")
    private String password;

    @Min(value = 18, message = "年龄必须成年")
    @Max(value = 120, message = "年龄不合法")
    private Integer age;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
}
第二步:接口参数上加 @Validated(或 @Valid)触发校验,用 BindingResult 收集错误。
java
 
package com.example.demo.controller;

import com.example.demo.dto.RegisterDTO;
import com.example.demo.entity.Result;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
public class RegisterController {

    @PostMapping("/register")
    public Result<String> register(@Validated @RequestBody RegisterDTO dto,
                                   BindingResult bindingResult) {
        // 校验失败:取出第一个错误信息返回
        if (bindingResult.hasErrors()) {
            String msg = bindingResult.getFieldError().getDefaultMessage();
            return Result.fail(400, msg);
        }
        return Result.ok("注册成功");
    }
}
关键规则:BindingResult 必须紧跟在被校验的参数后面,写错位置或直接漏写,校验失败会直接抛异常而不是进入 if 分支。
第三步:测试。
bash
 
# 合格数据
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" \
     -d '{"username":"tom","password":"123456","age":20,"email":"tom@qq.com","phone":"13800138000"}'

# 用户名为空 → 返回 {"code":400,"msg":"用户名不能为空"}
curl -X POST http://localhost:8080/register -H "Content-Type: application/json" \
     -d '{"username":"","password":"123456","age":15,"email":"bad-email"}'

6.3 进阶:一次性返回全部字段错误(商业项目做法)

java
 
if (bindingResult.hasErrors()) {
    Map<String, String> errors = new LinkedHashMap<>();
    bindingResult.getFieldErrors().forEach(e ->
            errors.put(e.getField(), e.getDefaultMessage()));
    return Result.fail(400, "参数校验失败").setDataAndReturn(errors);
}
实际项目一般不写 BindingResult,而是让校验异常抛出,由第 7 章的全局异常处理器统一格式化——Controller 里零校验代码。

6.4 分组校验(了解)

同一个 DTO 在不同场景规则不同(如新增时校验密码、更新时不校验),可定义分组接口:
java
 
public interface CreateGroup {}
public interface UpdateGroup {}

@NotBlank(groups = CreateGroup.class, message = "新增时密码必填")
private String password;

// 接口上指定分组:只有 CreateGroup 组的规则生效
public Result<?> add(@Validated(CreateGroup.class) @RequestBody RegisterDTO dto) { ... }

第 7 章 异常处理:局部与全局方案

7.1 核心知识点:三种方式,越往后越全局

  1. @ExceptionHandler(本类):写在某个 Controller 内部,只处理本类抛出的异常。
  2. @ControllerAdvice(全局,推荐):集中在一个类里处理所有 Controller 的异常;配合 @RestControllerAdvice 直接返回 JSON。
  3. 实现 HandlerExceptionResolver(框架级):底层接口,了解即可。
优先级:本类 @ExceptionHandler 优先于全局 Advice。

7.2 示例一:本类异常处理

java
 
@RestController
public class DemoController {

    @GetMapping("/div")
    public int div(int a, int b) {
        return a / b;   // b=0 时抛 ArithmeticException
    }

    // 捕获本类方法抛出的指定异常,返回友好提示
    @ExceptionHandler(ArithmeticException.class)
    public Result<String> handleArithmetic(ArithmeticException e) {
        return Result.fail(400, "除数不能为 0");
    }
}
测试:http://localhost:8080/div?a=10&b=0 → 返回友好 JSON,而不是 Tomcat 默认的 500 错误页。

7.3 示例二:全局异常处理 + 自定义业务异常(商业项目标准做法)

第一步:自定义业务异常,携带错误码。
java
 
package com.example.demo.exception;

public class BizException extends RuntimeException {
    private final int code;

    public BizException(int code, String msg) {
        super(msg);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}
第二步:全局处理器。
java
 
package com.example.demo.exception;

import com.example.demo.entity.Result;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice   // 全局生效,且返回 JSON
public class GlobalExceptionHandler {

    // ① 业务异常:错误码透传给前端
    @ExceptionHandler(BizException.class)
    public Result<String> handleBiz(BizException e) {
        return Result.fail(e.getCode(), e.getMessage());
    }

    // ② 参数校验异常(@RequestBody 校验失败抛出)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<String> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldError().getDefaultMessage();
        return Result.fail(400, msg);
    }

    // ③ 兜底:最后防线,任何未捕获的异常
    @ExceptionHandler(Exception.class)
    public Result<String> handleAll(Exception e) {
        e.printStackTrace();   // 生产环境用日志框架记录完整堆栈
        return Result.fail(500, "系统繁忙,请稍后再试");
    }
}
第三步:业务代码里直接抛异常,彻底告别 try-catch 污染。
java
 
@GetMapping("/stock/{num}")
public Result<String> stock(@PathVariable int num) {
    if (num > 100) {
        throw new BizException(1001, "库存不足,当前仅剩 100 件");
    }
    return Result.ok("下单成功");
}
这套组合的工程价值:Controller 和 Service 只关心成功路径,异常一路抛给全局处理器;所有响应格式统一为 Result,前端一套逻辑吃到底。
安全红线:永远不要把 e.getMessage() 或堆栈直接返回给前端(系统异常类)——表名、类名、路径都是攻击者的情报。对外统一话术,对内详细日志。

第 8 章 拦截器:登录鉴权实战

8.1 核心知识点

拦截器(HandlerInterceptor)在请求进入 Controller 前后插入自定义逻辑,典型用途:登录检查、权限校验、日志记录、耗时统计。
三个钩子方法:
表格
 
 
方法时机用途
preHandle Controller 执行前 返回 false 直接拦截(登录检查在这里)
postHandle Controller 执行后、视图渲染前 修改 ModelAndView
afterCompletion 请求完成后 资源清理、异常记录
两步缺一不可:实现接口只是写了逻辑,必须在配置类注册才会生效——新手最常忘第二步。

8.2 示例:登录检查拦截器

第一步:实现 HandlerInterceptor。
java
 
package com.example.demo.interceptor;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        Object user = request.getSession().getAttribute("loginUser");
        if (user == null) {
            // 页面项目:重定向到登录页
            // response.sendRedirect("/login");

            // 接口项目:返回 401 JSON
            response.setStatus(401);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已失效\"}");
            return false;   // 拦截,不再进入 Controller
        }
        return true;        // 放行
    }
}
第二步:注册并指定拦截范围。
java
 
package com.example.demo.config;

import com.example.demo.interceptor.LoginInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")                     // 拦截所有请求
                .excludePathPatterns("/login", "/register", // 放行登录注册
                                     "/hello", "/error",
                                     "/**/*.js", "/**/*.css"); // 放行静态资源
    }
}
测试:未登录直接访问 http://localhost:8080/users → 返回 401 JSON。

8.3 多个拦截器的执行顺序

注册顺序即执行顺序。preHandle 正序执行,postHandle 和 afterCompletion 倒序执行:
plain
 
请求 → 拦截器1.preHandle → 拦截器2.preHandle → Controller
     → 拦截器2.postHandle → 拦截器1.postHandle → 视图渲染
     → 拦截器2.afterCompletion → 拦截器1.afterCompletion → 响应
放行规则要想全:登录页、验证码、静态资源、健康检查都要放行,否则出现「登录页也要先登录」的重定向死循环(浏览器报「重定向次数过多」)。

第 9 章 文件上传与下载

9.1 核心知识点

文件上传三要素:
  1. 前端表单 enctype="multipart/form-data",method 为 POST;
  2. 后端用 MultipartFile 类型接收(参数名与表单 file 控件的 name 对应);
  3. 调用 transferTo() 把临时文件落盘。

9.2 示例一:单文件上传

java
 
package com.example.demo.controller;

import com.example.demo.entity.Result;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.UUID;

@RestController
public class UploadController {

    // 文件保存目录(生产环境放配置文件里)
    private static final String UPLOAD_DIR = "D:/upload/";

    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
        // 1. 空文件检查
        if (file.isEmpty()) {
            return Result.fail(400, "请选择文件");
        }

        // 2. 生成新文件名:UUID + 原扩展名(防重名覆盖、防中文乱码)
        String original = file.getOriginalFilename();
        String ext = original.substring(original.lastIndexOf("."));
        String newName = UUID.randomUUID() + ext;

        // 3. 落盘
        File dir = new File(UPLOAD_DIR);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        file.transferTo(new File(UPLOAD_DIR + newName));

        return Result.ok("上传成功:" + newName);
    }
}
Postman 测试:Body → form-data → key 填 file,类型下拉切换为 File,选择文件发送。

9.3 示例二:多文件上传

java
 
@PostMapping("/upload/batch")
public Result<List<String>> batchUpload(@RequestParam("files") MultipartFile[] files) throws IOException {
    List<String> names = new ArrayList<>();
    for (MultipartFile file : files) {
        if (!file.isEmpty()) {
            String newName = UUID.randomUUID() +
                    file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
            file.transferTo(new File(UPLOAD_DIR + newName));
            names.add(newName);
        }
    }
    return Result.ok(names);
}

9.4 文件大小限制配置

SpringBoot 默认单文件 1MB、单请求 10MB,超限直接抛异常。在 application.properties 调整:
properties
 
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=100MB

9.5 文件下载

java
 
@GetMapping("/download")
public ResponseEntity<byte[]> download(String filename) throws IOException {
    File file = new File(UPLOAD_DIR + filename);
    byte[] bytes = Files.readAllBytes(file.toPath());

    HttpHeaders headers = new HttpHeaders();
    // 中文文件名需要 URL 编码,否则乱码
    String encoded = URLEncoder.encode(filename, "UTF-8");
    headers.add("Content-Disposition", "attachment; filename=" + encoded);
    headers.add("Content-Type", "application/octet-stream");

    return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
}

9.6 商业项目上传的「四道防线」

  1. 类型白名单:只允许 image/jpeg、image/png 等,jsp/html 一律拒之门外(防上传 webshell);
  2. 大小校验:超规格直接拒绝;
  3. UUID 重命名:防重名覆盖、隐藏原始文件名、避免中文特殊字符引发存储问题;
  4. 路径安全:文件名不可信,禁止拼用户输入的路径(防 ../../ 路径穿越)。

第 10 章 高频补充:跨域、转发与重定向、Servlet API

10.1 跨域 CORS

前后端分离项目前端运行在 3000 端口、后端 8080,浏览器同源策略会拦截请求。后端开启跨域支持:
java
 
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")   // 允许的来源
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}
简单场景也可以直接在 Controller 上加 @CrossOrigin。

10.2 forward 与 redirect

java
 
@GetMapping("/go1")
public String forward() {
    return "forward:/hello";    // 服务器内部接力,1 次请求,地址栏不变
}

@GetMapping("/go2")
public String redirect() {
    return "redirect:/hello";   // 让浏览器重新发一次请求,2 次请求,地址栏变
}
经典原则 PRG 模式:所有「会改变数据」的 POST(下单、支付、注册)成功后一律重定向到 GET 页面,防止用户刷新页面重复提交表单。

10.3 原生 Servlet API 的获取

SpringMVC 允许直接在方法形参上声明,框架自动注入:
java
 
@GetMapping("/servlet")
public String servlet(HttpServletRequest request,
                      HttpServletResponse response,
                      HttpSession session) {
    String ip = request.getRemoteAddr();
    session.setAttribute("loginUser", "tom");
    return "ip=" + ip;
}

第 11 章 综合实战:商品管理模块完整代码

把前面所有技术点串起来:窄化映射(第 2 章)、三种参数来源(第 3 章)、统一返回体(第 4 章)、RESTful(第 5 章)、校验(第 6 章)、全局异常(第 7 章)。

11.1 DTO

java
 
package com.example.demo.dto;

import lombok.Data;
import javax.validation.constraints.*;
import java.math.BigDecimal;

@Data
public class ProductDTO {

    @NotBlank(message = "商品名不能为空")
    @Size(max = 50, message = "商品名最长 50 字")
    private String name;

    @NotNull(message = "价格不能为空")
    @DecimalMin(value = "0.01", message = "价格必须大于 0")
    private BigDecimal price;

    @Min(value = 0, message = "库存不能为负")
    private Integer stock;
}

11.2 Controller

java
 
package com.example.demo.controller;

import com.example.demo.dto.ProductDTO;
import com.example.demo.entity.Result;
import com.example.demo.exception.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    private final Map<Long, ProductDTO> store = new ConcurrentHashMap<>();
    private final AtomicLong idGen = new AtomicLong(0);

    // 分页查询:GET /api/v1/products?pageNum=1&pageSize=10&keyword=手机
    @GetMapping
    public Result<List<ProductDTO>> page(
            @RequestParam(defaultValue = "1") Integer pageNum,
            @RequestParam(defaultValue = "10") Integer pageSize,
            @RequestParam(required = false) String keyword) {
        List<ProductDTO> all = new ArrayList<>(store.values());
        // 省略真实分页与模糊查询逻辑,交给 Service 层
        return Result.ok(all);
    }

    // 查详情:GET /api/v1/products/1
    @GetMapping("/{id}")
    public Result<ProductDTO> detail(@PathVariable Long id) {
        ProductDTO p = store.get(id);
        if (p == null) {
            throw new BizException(404, "商品不存在");
        }
        return Result.ok(p);
    }

    // 新增:POST /api/v1/products
    @PostMapping
    public ResponseEntity<Result<Long>> create(@Validated @RequestBody ProductDTO dto) {
        long id = idGen.incrementAndGet();
        store.put(id, dto);
        return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(id));
    }

    // 修改:PUT /api/v1/products/1
    @PutMapping("/{id}")
    public Result<Void> update(@PathVariable Long id,
                               @Validated @RequestBody ProductDTO dto) {
        if (!store.containsKey(id)) {
            throw new BizException(404, "商品不存在");
        }
        store.put(id, dto);
        return Result.ok();
    }

    // 删除:DELETE /api/v1/products/1
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        if (store.remove(id) == null) {
            throw new BizException(404, "商品不存在");
        }
        return ResponseEntity.noContent().build();
    }
}

11.3 全流程测试脚本

bash
 
# 新增(缺价格,验证校验 + 全局异常)
curl -X POST http://localhost:8080/api/v1/products \
  -H "Content-Type: application/json" -d '{"name":"机械键盘"}'
# → {"code":400,"msg":"价格不能为空"}

# 正常新增
curl -X POST http://localhost:8080/api/v1/products \
  -H "Content-Type: application/json" -d '{"name":"机械键盘","price":299.00,"stock":50}'
# → 201 {"code":0,"msg":"success","data":1}

# 查列表 / 查详情 / 修改 / 删除
curl "http://localhost:8080/api/v1/products?pageNum=1&pageSize=10"
curl http://localhost:8080/api/v1/products/1
curl -X PUT http://localhost:8080/api/v1/products/1 \
  -H "Content-Type: application/json" -d '{"name":"机械键盘Pro","price":399.00,"stock":30}'
curl -X DELETE http://localhost:8080/api/v1/products/1 -v
curl http://localhost:8080/api/v1/products/999    # → {"code":404,"msg":"商品不存在"}

11.4 分层边界自检

Controller 只做四件事:收参数、调 Service、包结果、不管业务。判断一段代码该不该出现在 Controller:换掉 Web 框架它是否还有用?有用就下沉到 Service。

第 12 章 常见错误排查清单

表格
 
 
现象可能原因排查动作
404 URL 拼错;类上前缀漏写;Controller 未被扫描;GET/POST 方式不匹配 看启动日志中的映射清单
400 缺必填参数;参数类型转换失败(abc 传给 Long) 检查参数名与类型
405 请求方式不对(接口是 POST 却发了 GET) 核对 @PostMapping
415 Content-Type 不支持(JSON 接口却发表单) 请求头加 application/json
500 代码异常未处理 看控制台堆栈;配置全局异常处理
参数全是 null JSON 传参漏加 @RequestBody;实体类缺 setter 补注解 / 加 @Data
日期绑定失败 未声明日期格式 加 @DateTimeFormat / @JsonFormat
中文乱码 老项目响应编码不对 produces="application/json;charset=utf-8"
上传报大小超限 默认 1MB 限制 调整 multipart 配置
拦截器不生效 只实现接口没注册 检查 addInterceptors

附录:SpringBoot 3.x 的关键差异

如果项目使用 SpringBoot 3.x(JDK 17+),只有一处需要全员注意:
  • 包名从 javax.* 改为 jakarta.*:
    • javax.validation.constraints.NotBlank → jakarta.validation.constraints.NotBlank
    • javax.servlet.http.HttpServletRequest → jakarta.servlet.http.HttpServletRequest
  • 其余 API 写法完全一致,本教程代码只需批量替换 import 即可迁移。

教程完。建议学习顺序:第 0、1 章搭环境与理解流程 → 第 2、3、4 章练熟映射与参数响应(占日常开发 80%)→ 第 5 章做 CRUD 小项目 → 第 6、7、8、9 章进阶企业特性 → 最后独立完成第 11 章综合实战并自己写一个博客后台 API 作为结业练习。
posted @ 2026-08-08 17:17  鬼门元歌  阅读(16)  评论(0)    收藏  举报