18.SpringMVC请求处理与数据绑定

Spring MVC:从接收请求到返回页面与 JSON
做一个用户管理功能,浏览器发出“查询张三”的请求,后端就要取得查询条件、执行查询,再把结果交给页面或前端程序。Spring MVC 负责的,就是把 HTTP 请求接到 Java 方法上,再把方法的结果交回客户端。
它是 Spring 的 Web 框架。Spring Boot 可以帮我们完成常见配置,让项目更快运行起来,但实际处理请求的仍然是 Spring MVC。
下面先讲请求映射,再讲数据绑定,最后讲模型数据和响应;在这个基础上,再补充开发中常用的校验、异常处理和拦截器。示例使用 Java 17、Spring Boot 3.4.5 和 Spring Framework 6,Servlet、校验相关类型使用 jakarta 包。
1. 先认识 Spring MVC
1.1 MVC 分别管什么
MVC 把一次页面请求分成三部分:
- Model,模型:交给页面的数据,例如查询到的用户姓名。
- View,视图:展示数据的页面,例如 JSP 或 Thymeleaf 模板。
- Controller,控制器:接收请求,调用业务方法,再决定返回什么。
比如用户详情页需要显示张三的信息:Controller 接收用户编号,Service 查询用户,把结果放进 Model,View 再把姓名显示出来。这里的 Model 不等于某一个数据库实体类,它表示视图需要使用的数据。
前后端分离时,Controller 也可以直接返回 JSON,由 Vue 等前端程序展示。这时 Spring MVC 仍然处理请求和响应,只是不再由服务端模板渲染 HTML。
1.2 请求经过哪些地方
text
浏览器发送请求
↓
DispatcherServlet 接收请求
↓
HandlerMapping 查找匹配的控制器方法
↓
HandlerAdapter 准备参数,调用控制器方法
↓
把返回值处理成 HTML 页面或响应数据
DispatcherServlet 可以理解为请求的分发入口。它不负责查询用户、创建订单这些业务,而是把请求交给合适的方法。后面的映射注解、参数绑定和响应注解,分别参与这条流程中的不同步骤。
1.3 准备运行环境
建立一个 Java 17 的 Maven 项目,父项目使用以下版本:
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
在 dependencies 中加入 Web 依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
它提供 Spring MVC、JSON 转换和内嵌 Tomcat 等常用组件。主程序放在 com.example.mvc 包下:
java
package com.example.mvc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MvcApplication {
public static void main(String[] args) {
SpringApplication.run(MvcApplication.class, args);
}
}
后面的控制器、表单类和配置类放在这个包或它的子包中,Spring 才能按默认扫描规则发现它们。每个类分别编写,方法片段会说明加入哪个类;重复的包名与 import 不逐段展开,IDE 可以根据类名补齐。
2. 请求映射:哪个方法处理哪个请求
2.1 @RequestMapping 的作用
用户管理中有查询、新增、删除等操作。后端需要知道哪个地址对应哪个方法,@RequestMapping 就用来建立这种对应关系。
先写一个最小控制器:
java
@RestController
@RequestMapping("/user")
public class MappingController {
@RequestMapping("/index")
public String index() {
return "用户首页";
}
}
访问 http://localhost:8080/user/index,会看到“用户首页”。类上的 /user 是公共前缀,方法上的 /index 是具体路径,两者拼起来才是完整地址。
@RestController 表示把返回值写入响应体,所以字符串会作为文字返回。返回模板页面的写法放在后面讲。
2.2 value:指定路径
下面两种写法等价:
java
@RequestMapping("/index")
java
@RequestMapping(value = "/index")
没有写属性名时,传入的就是路径值。path 也可以表示相同的路径条件。
2.3 method:限定请求方法
查询用户常用 GET,提交新增数据常用 POST。只写路径时,@RequestMapping 没有限定具体 HTTP 方法;需要限制时,要加上 method。
把前面的 index 方法替换为:
java
@RequestMapping(value = "/index", method = RequestMethod.POST)
public String index() {
return "收到 POST 请求";
}
此时浏览器地址栏发出的 GET 请求不能调用它,通常得到 405。使用接口工具发送 POST http://localhost:8080/user/index,才会进入方法。
也可以改用等价的简写:
java
@PostMapping("/index")
public String index() {
return "收到 POST 请求";
}
@GetMapping、@PostMapping、@PutMapping、@DeleteMapping 和 @PatchMapping,都是在 @RequestMapping 基础上限定了 HTTP 方法。开发接口时,通常直接使用这些简写。
2.4 params:增加参数条件
有时同一个路径需要根据参数决定能否匹配。例如只处理带有姓名条件的查询,在 MappingController 中增加:
java
@GetMapping(value = "/search", params = "name")
public String search(@RequestParam("name") String name) {
return "查询姓名:" + name;
}
请求 /user/search?name=张三 可以匹配,/user/search 不满足条件。
params = "name" 只要求参数存在,不代表内容一定有效;params = "mode=simple" 则要求指定参数具有指定值。姓名是否为空,还要按业务规则检查。
3. 获取参数:先看数据放在哪里
请求数据可以放在查询字符串、表单字段、URL 路径或请求体中。先分清位置,再选择注解,就不会把几种传参方式混在一起。
这一节使用一个单独的控制器:
java
@RestController
@RequestMapping("/users")
public class UserParamController {
// 下面各小节的方法放在这里。
}
3.1 查询参数与表单字段:@RequestParam
用户列表按姓名和编号筛选,地址可以写成:
text
GET /users/list?name=张三&id=5
在 UserParamController 中加入:
java
@GetMapping("/list")
public String list(
@RequestParam("name") String name,
@RequestParam("id") Integer id) {
// 先观察取到的参数,实际开发时交给 Service 查询。
return "姓名:" + name + ",编号:" + id;
}
响应为“姓名:张三,编号:5”。@RequestParam("id") 从请求中取名为 id 的参数,再转换为 Integer。
普通 HTML 表单的字段也能这样接收。查询字符串通常出现在 URL 中,表单字段通常放在请求体中,但两者都可以由 @RequestParam 获取;它不负责解析 JSON 对象。
@RequestParam 默认要求参数存在。筛选条件可不填时,可以提供默认值或使用可空的包装类型:
java
@GetMapping("/search")
public String search(
@RequestParam(value = "name", defaultValue = "") String name,
@RequestParam(value = "age", required = false) Integer age) {
return "姓名条件:" + name + ",年龄条件:" + age;
}
访问 /users/search 时,name 为 "",age 为 null。默认值适合分页条数、排序方式这样的设置;required = false 适合允许缺省的条件。
3.2 路径变量:@PathVariable
查询“编号为 5 的用户”时,可以把编号放在路径里:
text
GET /users/5
对应方法如下:
java
@GetMapping("/{id}")
public String detail(@PathVariable("id") Integer id) {
return "正在查看编号为 " + id + " 的用户";
}
{id} 是路径占位符,@PathVariable("id") 把它取出来。参数名明确写在注解里,可以避免 Java 编译参数名配置影响绑定。
多个路径变量也可以分别接收。例如请求 /users/list/张三/5,对应方法为:
java
@GetMapping("/list/{name}/{id}")
public String pathList(
@PathVariable("name") String name,
@PathVariable("id") Integer id) {
return "姓名:" + name + ",编号:" + id;
}
它在语法上成立,但地址不是“改成斜杠”就自动变成合理的 REST 接口。
REST 风格更关注资源:/users/5 表示一个用户,GET 表示查询,DELETE 表示删除。姓名、年龄等可选筛选条件通常继续放在查询参数中,例如 /users?name=张三,不需要全部改成路径变量。
3.3 JSON 请求体:@RequestBody
新增用户时,前端经常一次提交多个字段:
http
POST /users
Content-Type: application/json
{
"name": "张三",
"age": 18
}
先定义接收数据的 UserForm。它只包含本次业务允许用户填写的字段:
java
public class UserForm {
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
在 UserParamController 中加入:
java
@PostMapping
public UserForm create(@RequestBody UserForm form) {
// 暂时原样返回,先观察 JSON 是否正确转成对象。
return form;
}
@RequestBody 通过消息转换器读取请求体。Web starter 默认带有 Jackson,所以上面的 JSON 可以转换为 UserForm;返回时又被转换为 JSON:
json
{"name":"张三","age":18}
实际新增时,应在这里调用 Service 完成保存,再返回结果。写了 @RequestBody 不代表已经保存到数据库。
同样,@RequestBody 不用于把返回值转成 JSON。负责响应体的是 @ResponseBody,而 @RestController 已经包含了它的作用。
4. 数据绑定:把请求数据变成 Java 参数
数据绑定就是把请求中的值交给 Java 参数或对象属性。查询字符串和普通表单字段通常先以文本形式到达服务器,Spring MVC 再按目标类型转换。
例如 age=18 可以转成 Integer,age=abc 则不能。JSON 请求体也会经历反序列化,但它通过消息转换器完成,不能简单理解为所有 HTTP 请求数据原本都是字符串。
这一节的方法继续放在 UserParamController 中;路径各不相同,可以一起使用。
4.1 基本数据类型与包装类型
必填年龄可以这样接收:
java
@GetMapping("/age")
public String age(@RequestParam("age") int age) {
return "年龄:" + age;
}
访问 /users/age?age=18,得到“年龄:18”。如果年龄可以不填,应使用包装类型:
java
@GetMapping("/optional-age")
public String optionalAge(
@RequestParam(value = "age", required = false) Integer age) {
// Integer 可以用 null 表示没有填写,int 不可以。
return age == null ? "没有填写年龄" : "年龄:" + age;
}
不要用 int 接收可能缺失且没有默认值的参数,否则 Spring 需要表示“没有值”时,基本类型无法承接。
4.2 数组与简单集合
批量操作用户前,前端可能提交多个编号:
text
GET /users/ids?id=2&id=5&id=8
可以直接接成数组:
java
@GetMapping("/ids")
public Integer[] ids(@RequestParam("id") Integer[] ids) {
return ids;
}
响应为 [2,5,8]。同名参数的多个值会组成数组,也可以直接接成简单类型的 List:
java
@GetMapping("/id-list")
public List<Integer> idList(@RequestParam("id") List<Integer> ids) {
return ids;
}
因此“Spring MVC 不支持直接绑定 List”并不准确。它能绑定这种简单集合,也能用 @RequestBody List<UserForm> 接收 JSON 数组;常见的包装对象用法是后面的普通表单对象列表。
上面的 GET 只用于观察参数,不应真的执行删除。实际批量删除要选择合适的修改接口,并由业务层校验操作权限。
4.3 多个字段绑定到一个对象
筛选条件较多时,逐个写形参会变长。继续使用前面的 UserForm,接收普通表单或查询参数:
java
@GetMapping("/form")
public UserForm form(@ModelAttribute UserForm form) {
return form;
}
请求如下:
text
GET /users/form?name=张三&age=18
Spring MVC 创建对象,再根据 name、age 找到相应的 setter,填入数据。@ModelAttribute 表示按请求参数绑定对象;对这种普通复杂对象参数,符合规则时省略注解也能绑定,入门时显式写出更容易看懂。
这个方法与前面的 JSON 方法虽然都接收 UserForm,数据来源却不同:@ModelAttribute 接表单或查询参数,@RequestBody 接 JSON 等请求体。
业务中建议使用专门的表单对象,而不是暴露整个数据库实体。例如用户编辑资料时,只开放姓名和年龄,不把管理员标记、账户余额也交给请求绑定。
4.4 级联对象:绑定收货地址
用户填写资料时还可能填写地址。先定义一个只含城市的地址类:
java
public class Address {
private String city;
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
}
再定义包含地址的表单:
java
public class UserAddressForm {
private String name;
private Address address = new Address();
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
}
在 UserParamController 中加入:
java
@PostMapping("/address")
public UserAddressForm address(@ModelAttribute UserAddressForm form) {
return form;
}
用普通表单发送:
text
POST /users/address
Content-Type: application/x-www-form-urlencoded
name=张三&address.city=杭州
address.city 表示“进入 address 对象,再设置 city”。响应中会看到:
json
{"name":"张三","address":{"city":"杭州"}}
如果接收 JSON,请求体直接写成嵌套对象,再用 @RequestBody UserAddressForm form 接收。点号属于表单字段命名,不是嵌套 JSON 的写法。
4.5 对象列表:用包装对象接批量表单
一次录入两名用户时,普通表单需要表达每一行属于哪个对象。先定义包装类,把集合放在对象属性中:
java
public class UserBatchForm {
private List<UserForm> users = new ArrayList<>();
public List<UserForm> getUsers() {
return users;
}
public void setUsers(List<UserForm> users) {
this.users = users;
}
}
控制器增加:
java
@PostMapping("/batch-form")
public List<UserForm> batchForm(@ModelAttribute UserBatchForm form) {
return form.getUsers();
}
普通表单字段使用下标:
text
POST /users/batch-form
Content-Type: application/x-www-form-urlencoded
users[0].name=张三&users[0].age=18&users[1].name=李四&users[1].age=20
Spring MVC 根据 users[0]、users[1] 组装列表,再为每一项设置姓名与年龄。包装类让绑定器知道列表属于哪个属性,以及每一项是什么类型。
如果前端使用 JSON 数组,写法更直接:
java
@PostMapping("/batch-json")
public List<UserForm> batchJson(@RequestBody List<UserForm> users) {
return users;
}
对应请求体:
json
[
{"name":"张三","age":18},
{"name":"李四","age":20}
]
两种方式都能批量提交,选择依据是前端实际发送的格式,不是“List 一定要包装”。
4.6 日期参数也需要格式约定
按生日筛选时,前后端要约定日期写法:
java
@GetMapping("/birthday")
public String birthday(
@RequestParam("date")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
return "生日:" + date;
}
请求 /users/birthday?date=2000-01-02,得到“生日:2000-01-02”。@DateTimeFormat 用于查询参数或表单参数转换;JSON 日期格式由 JSON 转换器配置,不要把两者混用。
5. 页面传值:用 Model 交给模板,用 request 和 session 保存属性
前面的控制器直接返回文字或 JSON。对于服务端渲染的用户详情页,后端还要完成两件事:准备姓名、年龄这些展示数据,再告诉模板从哪里读取。
这里会遇到几个名字:UserVO 表示准备展示的数据,Model 用来把数据交给模板,request 和 session 则涉及属性保存在哪个范围、能保留多久。先分清它们各自的作用,再看代码。
5.1 配置页面模板
以 Thymeleaf 为例,加入:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Thymeleaf 是服务端模板引擎:HTML 保留页面结构,表达式显示 Model 中的数据。JSP 也能承担视图职责,但它需要相应的容器和视图配置,不能直接套用这里的 Thymeleaf 目录规则。
5.2 先分清 UserVO 和 Model
VO 是为页面或接口准备的展示对象,例如 UserVO 只放用户详情页需要的姓名和年龄。数据库实体可能还有密码等字段,展示时就不应该全部交出去。
VO 常指 View Object,是项目中的命名约定,Spring MVC 不强制要求这个后缀。接收表单的类可以叫 UserForm,向前端提供展示数据的类可以叫 UserVO;字段恰好相同,也不意味着它们承担的用途相同。前面直接返回 UserForm,是为了观察绑定结果,下面把接收数据与展示数据分开。
先定义一个简单的 UserVO:
java
public class UserVO {
private final String name;
private final Integer age;
public UserVO(String name, Integer age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public Integer getAge() {
return age;
}
}
这个例子在创建时一次填好展示内容,getter 供模板或 JSON 转换器读取,所以暂时不需要 setter。
Model 是 Spring MVC 提供的一个接口,完整名称是 org.springframework.ui.Model。可以先把它理解为“交给模板的一组有名字的数据”:名字供模板查找,值可以是 UserVO、列表或普通字符串。
Controller 方法声明 Model model 后,Spring MVC 会在调用方法时提供它,我们不用自己 new。下面单独编写页面控制器:
java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/user-page")
public class UserPageController {
@GetMapping("/detail")
public String detail(Model model) {
UserVO user = new UserVO("张三", 18);
// 给展示对象起名为 user,模板按这个名字读取。
model.addAttribute("user", user);
// 返回模板名称,数据则已经放进 Model。
return "user-detail";
}
}
为了集中观察传值过程,这里直接创建一条数据。实际详情页通常先调用 Service 查询,再整理成 UserVO。
addAttribute("user", user) 的第一个参数是模板使用的名字,第二个参数才是要展示的对象。UserVO 装着姓名和年龄,Model 把这个对象以 user 这个名字交给模板,两者配合使用。
5.3 模板显示数据
在 src/main/resources/templates 下建立 user-detail.html:
html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>用户详情</title>
</head>
<body>
<h1>用户详情</h1>
<p>姓名:<span th:text="${user.name}">示例姓名</span></p>
<p>年龄:<span th:text="${user.age}">0</span></p>
</body>
</html>
访问 http://localhost:8080/user-page/detail,页面显示“姓名:张三”“年龄:18”。
整个过程是:Controller 把对象命名为 user,返回视图名 user-detail,视图解析器找到模板,再用数据渲染出 HTML。返回的是视图名称,不是模板文件的全部路径。
5.4 ModelAndView:把数据与视图放在一起返回
前面用 Model 放数据,再用返回值指定模板。如果希望把这两部分放在一起返回,可以使用 ModelAndView。它是 Spring MVC 提供的对象,同时保存视图名称和页面数据。在 UserPageController 中增加:
java
@GetMapping("/detail-view")
public ModelAndView detailView() {
UserVO user = new UserVO("李四", 20);
// 构造参数指定模板名称。
ModelAndView result = new ModelAndView("user-detail");
// 与 model.addAttribute 一样,给页面数据起名为 user。
result.addObject("user", user);
return result;
}
ModelAndView 来自 org.springframework.web.servlet.ModelAndView,同时保存“用哪个视图”和“给视图哪些数据”。
前一种写法用 Model 放数据,再返回字符串视图名;这里把两部分装进 ModelAndView 一起返回。两种写法都能渲染同一个模板,访问 /user-page/detail-view 时,页面显示李四和 20 岁。
到这里,我们已经能把数据交给当前页面了。不过,页面跳转后还能不能取到这些数据,还要看它们保存的范围。下面把 request 域和 session 域放在一起说明。
5.5 request 域:保存本次请求要用的数据
这里的“域”,就是保存属性并规定它们在哪些请求中可用的范围。request 域对应一次 HTTP 请求,适合用户详情、查询列表、错误提示这些只供本次页面显示的数据。
Servlet API 使用 HttpServletRequest 存取请求属性:
java
// 保存属性,user 是前面准备好的 UserVO。
request.setAttribute("user", user);
// 同一次请求中的其他处理代码可以按名字取出。
UserVO savedUser = (UserVO) request.getAttribute("user");
request 的类型是 jakarta.servlet.http.HttpServletRequest,也可以声明为 Controller 的方法参数,由框架传入。
前面用 Model,也是为了给本次页面渲染提供数据。Spring MVC 会把这些数据交给视图处理:JSP 视图通常把它们暴露为 request 属性,Thymeleaf 则把它们加入模板使用的数据环境。因此,在当前示例中调用 model.addAttribute("user", user) 后,模板就能通过 ${user.name} 读取,不需要再重复调用 setAttribute。不要把 Model 本身当成 HttpServletRequest。
这也解释了为什么跳转方式很重要:
- 转发继续使用同一次请求,request 属性可以传给接下来的处理代码或页面。
- 重定向让浏览器重新发起请求,新请求不会自动带上上一次的 request 属性。
例如返回 "redirect:/user-page/detail" 时,不应指望重定向前放进 Model 的 UserVO 自动成为新请求里的同一个对象。重定向传值可以另用 URL 参数或临时的 flash 属性;当前页面展示先掌握普通的请求属性即可。
5.6 session 域:在同一会话的多次请求间保留数据
如果希望用户提交昵称后,刷新或访问其他页面时仍能看到它,只放 request 就不够了。这时可以使用 session 域。
HttpSession 保存同一会话的数据,浏览器通常通过携带会话 Cookie,让服务器找到对应的 Session。它能跨多次请求保留属性,但不是所有用户共用的一份数据。
在 UserPageController 中导入 jakarta.servlet.http.HttpSession、PostMapping 和 RequestParam,增加:
java
@PostMapping("/remember-name")
public String rememberName(
@RequestParam("name") String name,
HttpSession session) {
// 保存到会话中,接下来的新请求仍然可以读取。
session.setAttribute("displayName", name);
return "redirect:/user-page/detail";
}
在 user-detail.html 的 body 中加入表单和显示区域:
html
<form th:action="@{/user-page/remember-name}" method="post">
<input type="text" name="name" placeholder="填写昵称" required>
<button type="submit">记住昵称</button>
</form>
<!-- session 前缀明确表示从会话中读取。 -->
<p th:if="${session.displayName != null}">
你好,<span th:text="${session.displayName}">访客</span>
</p>
提交“小明”后,浏览器重定向并重新请求详情页,页面仍会显示“你好,小明”。这是一次新请求,却能读取之前保存的昵称,因为它存进了 session 域。
昵称保存后,后面的 Java 处理代码也能读取;不再需要时,可以删除它:
java
// 同一会话的后续请求中,可以读到之前保存的昵称。
String displayName = (String) session.getAttribute("displayName");
// 只清除昵称,其他会话属性仍然保留。
session.removeAttribute("displayName");
如果需要让整个会话失效,可以调用 session.invalidate()。会话超时也会清除其中保存的属性。因此,session 能跨请求保存数据,但不表示永久保存。
把两者放在一起看,选择就比较清楚了:本次查询结果交给 request;后续请求还要用的少量会话信息,才考虑 session。
| 对比项 | request 域 | session 域 |
|---|---|---|
| 保存 | request.setAttribute("名字", 值) |
session.setAttribute("名字", 值) |
| Java 读取 | request.getAttribute("名字") |
session.getAttribute("名字") |
| 有效范围 | 当前请求,包括同一次请求的转发 | 同一会话中的多次请求 |
| Thymeleaf 读取示例 | ${user.name} |
${session.displayName} |
| 常见用途 | 本次查询结果、页面提示 | 少量会话信息、跨请求保留的用户偏好 |
VO 决定“数据里有哪些字段”,Model 帮助“把哪些数据交给当前模板”,request 与 session 决定“属性能在哪个范围内使用”。不要因为多个页面都会查询用户,就把所有查询结果长期放进 Session;这里只演示记住昵称,也没有实现身份认证。
6. 返回 JSON:供前端页面或 App 使用
页面由前端程序渲染时,后端通常只提供数据。@ResponseBody 让 Spring MVC 把返回值写进 HTTP 响应体;@RestController 相当于在控制器上统一应用 @Controller 和 @ResponseBody。
例如单独定义接口控制器,继续使用前面的 UserVO:
java
@RestController
@RequestMapping("/api/users")
public class UserApiController {
@GetMapping("/{id}")
public UserVO detail(@PathVariable("id") Integer id) {
// 用固定展示数据观察 JSON 响应,实际业务按 id 查询。
return new UserVO("张三", 18);
}
}
访问 /api/users/5,响应体是 {"name":"张三","age":18},不会查找模板。Jackson 读取 UserVO 的 getter,将其转换成 JSON;浏览器收到的是 JSON 数据,不会收到 Java 类名中的 VO 后缀。id 在实际业务中应交给查询方法。
这里直接返回 UserVO,由消息转换器写入响应即可,不需要为了返回 JSON 再把它放入 Model。
如果在 @RestController 中返回 "user-detail",浏览器得到的是这串文字,不能靠模板名打开页面。
REST 接口常用以下约定:
| 业务操作 | HTTP 方法 | 示例路径 |
|---|---|---|
| 查询用户 | GET | /api/users/5 |
| 新增用户 | POST | /api/users |
| 整体更新用户 | PUT | /api/users/5 |
| 更新部分字段 | PATCH | /api/users/5 |
| 删除用户 | DELETE | /api/users/5 |
请求方法表达操作,路径表达资源。复杂业务仍要结合实际需求设计,不必为了形式把所有动作都硬套成一种地址。
7. 参数校验:能转成对象,不代表数据合法
age=-1 可以转成整数,但它不是合理年龄;空字符串也能成为 String,却不应该作为姓名。新增资料、提交订单等业务,都需要在业务处理前检查输入。
7.1 为表单声明规则
先加入校验依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
在前面的 UserForm 的两个字段上增加校验注解,getter 和 setter 保持原样:
java
@NotBlank(message = "姓名不能为空")
private String name;
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于 0")
private Integer age;
注解来自 jakarta.validation.constraints。@Min 本身允许 null,年龄必填时,还要增加 @NotNull。
7.2 接收数据后触发校验
把第 3 节的 create 方法替换为:
java
@PostMapping
public UserForm create(@Valid @RequestBody UserForm form) {
return form;
}
@Valid 来自 jakarta.validation。请求体转换成 UserForm 后,Spring MVC 检查字段约束;姓名为空或年龄不合法时,不会正常执行方法,通常返回 400。
前面不带 @Valid 的方法不会仅因字段上新增了注解就执行同样的校验。声明规则与触发校验是两步。
7.3 普通表单:用 BindingResult 接住错误
服务端表单还可以在原页面显示提示。下面的方法放进 UserParamController,先返回文字观察错误:
java
@PostMapping("/checked-form")
public String checkedForm(
@Valid @ModelAttribute UserForm form,
BindingResult errors) {
// BindingResult 紧跟被校验的对象,接收它的绑定与校验错误。
if (errors.hasErrors()) {
return errors.getAllErrors().get(0).getDefaultMessage();
}
return "可以提交:" + form.getName();
}
需要返回 HTML 表单页时,可以把这种处理放入 @Controller,校验失败就返回表单模板。格式校验解决“填得是否合法”,重名、库存不足等业务规则仍需在 Service 中检查。
8. 统一异常处理:给前端一致的错误提示
数据不合法时,不应让前端只看到难以理解的服务器异常。@RestControllerAdvice 可以集中处理多个控制器的错误。
下面的处理器把前一节的 JSON 参数校验错误转换成简短提示:
java
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> invalidForm(
MethodArgumentNotValidException ex) {
String message = ex.getBindingResult()
.getAllErrors()
.get(0)
.getDefaultMessage();
// badRequest() 设置 HTTP 400,body() 设置响应文字。
return ResponseEntity.badRequest().body(message);
}
}
提交 {"name":"","age":18},得到 HTTP 400,响应体为“姓名不能为空”。ResponseEntity 可以同时控制状态码、响应头和响应体。
@ExceptionHandler 指定处理哪一种异常;@RestControllerAdvice 让这些方法作用于多个控制器,并把结果写入响应体。单个控制器内部也能定义 @ExceptionHandler。
这个处理器只覆盖所声明的异常。age=abc 的类型转换错误属于另一类异常,实际项目可按需要补充。不能把所有异常都当成“参数错误”,也不要把数据库密码或完整堆栈返回给用户。
9. 拦截器:在控制器前后做统一处理
多个管理接口可能都要记录耗时,如果每个方法都手写计时代码,就会重复。Spring MVC 的 HandlerInterceptor 能在方法执行前后统一插入这类操作。
9.1 编写一个耗时拦截器
java
public class TimingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute("startNanos", System.nanoTime());
return true; // 继续执行后面的拦截器和控制器。
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
long start = (Long) request.getAttribute("startNanos");
long millis = (System.nanoTime() - start) / 1_000_000;
System.out.println(request.getRequestURI() + " 耗时:" + millis + "ms");
}
}
preHandle 在调用控制器前执行,返回 false 会结束本次处理链,所以拦截器也能做前置检查。afterCompletion 在请求完成后收尾,这里只观察普通同步请求的耗时。
还有 postHandle,它在控制器正常执行后、视图渲染前调用;异常时不一定经过它。需要结束清理时,通常要考虑 afterCompletion。
9.2 注册拦截路径
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TimingInterceptor())
.addPathPatterns("/users/**", "/api/users/**");
}
}
访问 /users/list?name=张三&id=5 后,控制台打印请求路径与耗时,响应内容仍由控制器决定。拦截器不是写了类就会执行,还需要注册并匹配路径。
WebMvcConfigurer 用来补充 MVC 配置。在正常使用 Spring Boot 自动配置时,不要仅为了加拦截器就添加 @EnableWebMvc;它会让 Boot 的 MVC 自动配置退让,往往超出了这个需求。
9.3 Filter 与 Interceptor 怎么选
Servlet Filter 在 Servlet 容器层工作,位于 DispatcherServlet 外层,能用于请求编码、安全过滤等操作;Spring MVC 拦截器围绕已匹配的处理器工作,适合取得控制器信息、做相关的前后处理。
认证授权通常交给 Spring Security 等专门框架。拦截器可以演示登录检查,但不是完整安全方案的替代品。
10. 遇到错误,先看请求处在哪一步
10.1 404:没有找到资源
先检查地址是否包含类上的路径前缀,再检查控制器是否处于组件扫描范围。资源或业务对象不存在也可能返回 404,不能只凭状态码断定“Controller 没扫描到”。
10.2 405:路径有了,请求方法不对
例如写了 @PostMapping("/index"),却在地址栏直接访问。地址栏通常发 GET,应改为真正发送 POST,不要随手把后端接口全部改成 GET。
10.3 400:请求数据不能按约定处理
常见原因有必填参数缺失、age=abc 无法转成数字、JSON 格式错误以及校验未通过。先核对参数位置、名称和格式,再看服务端的异常类型。
10.4 415:提交格式不支持
方法用 @RequestBody 接 JSON,却提交普通表单,或者 Content-Type 与请求体不一致,可能得到 415。不要只检查字段内容,也要检查请求格式。
10.5 500:服务端出现未处理异常
例如访问了空对象、业务方法抛出异常或模板表达式写错。应查看日志中的异常原因与位置,不能靠修改前端参数盲目尝试。
用“查询用户并展示页面”串起来:映射注解先选中方法,绑定把编号交给参数,Controller 调用 Service 查询,把结果放入 Model,再返回视图名;模板把数据渲染成 HTML。前后端分离时,则把后面两步换成返回对象并转换为 JSON。

评论