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 五大核心组件(面试必问)
一次请求从进来到出去,由这五个角色接力完成:
-
DispatcherServlet(前端控制器):唯一入口,所有请求先到这里,由它统一调度。本质是一个 Servlet。
-
HandlerMapping(处理器映射器):根据请求 URL,找到应该执行哪个 Controller 的哪个方法。
-
HandlerAdapter(处理器适配器):以统一方式调用处理方法,并完成参数绑定。
-
Handler(处理器):就是我们写的
@Controller里的方法——五大组件中唯一需要程序员编写的。 -
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 核心知识点:三种方式,越往后越全局
-
@ExceptionHandler(本类):写在某个 Controller 内部,只处理本类抛出的异常。 -
@ControllerAdvice(全局,推荐):集中在一个类里处理所有 Controller 的异常;配合@RestControllerAdvice直接返回 JSON。 -
实现
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 核心知识点
文件上传三要素:
-
前端表单
enctype="multipart/form-data",method 为 POST; -
后端用
MultipartFile类型接收(参数名与表单 file 控件的 name 对应); -
调用
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 商业项目上传的「四道防线」
-
类型白名单:只允许
image/jpeg、image/png等,jsp/html 一律拒之门外(防上传 webshell); -
大小校验:超规格直接拒绝;
-
UUID 重命名:防重名覆盖、隐藏原始文件名、避免中文特殊字符引发存储问题;
-
路径安全:文件名不可信,禁止拼用户输入的路径(防
../../路径穿越)。
第 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 作为结业练习。
浙公网安备 33010602011771号