← 返回首页

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

2026-10-20·0 次浏览
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。

11. 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000