第36篇:Spring Boot进阶:Web开发+参数校验+全局异常处理

前言

这是Java专栏的第36篇。前面我们聊了MyBatis的进阶用法、Spring的IOC和AOP,这篇把目光转向Spring Boot Web开发------准确说,是把日常开发中最常用的那几块串起来:Controller怎么写、参数怎么接、校验怎么做、异常怎么统一处理、静态资源怎么配、接口文档怎么生成。

很多人写Spring Boot接口就是"能跑就行",参数校验散落在Controller里、异常处理各写各的、返回格式五花八门------项目一复杂,维护成本指数级上升。这篇我整理了一套自己在项目中用了很久的规范写法,从基础到实战,看完直接能套到自己的项目里。

环境说明:Spring Boot 2.7.x / JDK 8+,Maven项目。Spring Boot 3.x用Jakarta EE那套包名,注解用法基本一致,就是import路径换一下,文末会提。

一、Spring Boot Web开发基础

1.1 Controller与RequestMapping

Spring Boot Web开发的起点就是Controller。只要引入spring-boot-starter-web,内嵌Tomcat就自动配置好了,开箱即用。

@RestController = @Controller + @ResponseBody。这是最基础的组合,返回值直接序列化成JSON,不走视图解析器。现在前后端分离的项目,Controller层基本全用@RestController。

java 复制代码
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        return new User(id, "张三", 25);
    }
}

@RequestMapping是核心注解,用来映射请求路径。它可以用在类上和方法上,类上的路径是方法上路径的前缀。常用的派生注解有四个:

  • @GetMapping --- 查询

  • @PostMapping --- 新增

  • @PutMapping --- 修改

  • @DeleteMapping --- 删除

这四个注解本质上都是@RequestMapping的简写,指定了method属性。写接口的时候直接用这四个,语义更清晰。

还有几个容易忽略的属性:

  • produces:指定响应的Content-Type,比如produces = "application/json;charset=UTF-8"

  • consumes:指定请求的Content-Type,限制接收什么类型的请求体

  • params:请求参数条件,比如params = "type=admin",只有带这个参数才匹配

  • headers:请求头条件,用法类似params

这些属性在做接口版本控制、条件路由的时候会用到,日常开发用得不多,但要知道有这个东西。

1.2 请求参数接收的几种方式

请求参数接收是日常开发中用得最多的,Spring Boot提供了好几种方式,每种适用场景不一样。

1. 路径变量 @PathVariable

URL路径上的参数,比如 /api/users/1 中的1。RESTful风格的接口大量使用这种方式。

java 复制代码
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
    // ...
}

如果方法参数名和路径变量名不一样,可以用@PathVariable("userId")指定名称。

2. 请求参数 @RequestParam

URL问号后面的参数,比如 /api/users?name=张三&page=1。这是最传统的方式。

java 复制代码
@GetMapping("/list")
public List<User> listUsers(
    @RequestParam(defaultValue = "1") int page,
    @RequestParam(defaultValue = "10") int size,
    @RequestParam(required = false) String name) {
    // ...
}

几个常用属性:required指定是否必填,defaultValue给默认值。分页参数一般都要设默认值,不然前端不传就报错。

3. 请求体 @RequestBody

POST/PUT请求,JSON格式的请求体。前后端分离项目中,新增、修改接口基本都用这个。

java 复制代码
@PostMapping
public User createUser(@RequestBody User user) {
    // ...
}

Spring Boot默认用Jackson做JSON序列化和反序列化。需要注意的是,@RequestBody的参数必须有对应的setter方法,或者用@NoArgsConstructor + @Data,否则反序列化会失败。

4. 表单参数 @RequestParam 或直接接收

application/x-www-form-urlencoded格式的表单提交,可以直接用@RequestParam,也可以直接用实体类接收(不用加注解)。

java 复制代码
@PostMapping("/login")
public String login(@RequestParam String username, 
                    @RequestParam String password) {
    // ...
}

5. 请求头 @RequestHeader 和 Cookie @CookieValue

这两个用得少一些,但做鉴权、跨域处理的时候会碰到。

java 复制代码
@GetMapping("/profile")
public User profile(@RequestHeader("Authorization") String token) {
    // ...
}

6. 直接用实体类接收(GET请求)

GET请求参数多的时候,一个个写@RequestParam太麻烦,可以直接用实体类接收,不用加任何注解,Spring会自动绑定。

java 复制代码
@GetMapping("/search")
public List<User> search(UserQuery query) {
    // query里的字段会自动绑定URL参数
    // query.getPage(), query.getName()...
}

这个方式很多人不知道,参数多的时候特别好用。注意实体类必须有默认构造方法和setter。

**经验之谈:**参数少于3个直接写在方法上,多于3个就封装成对象。查询接口参数多的,专门建一个Query类,别用Entity直接接,职责要分清。

二、RESTful API设计规范

2.1 什么是RESTful

REST这个词是Roy Fielding在2000年的博士论文里提出来的,全称是Representational State Transfer(表述性状态转移)。听起来很玄乎,其实说白了就是一套设计Web API的风格和约束。

RESTful API的核心思想是:把所有东西都看作"资源",用HTTP方法来表示对资源的操作。URL里只有名词,没有动词------这是最直观的特征。

举个对比就清楚了:

传统风格 RESTful风格
GET /getUser?id=1 GET /api/users/1
POST /addUser POST /api/users
POST /updateUser PUT /api/users/1
GET /deleteUser?id=1 DELETE /api/users/1
GET /listUsers GET /api/users

看到区别了吧?RESTful风格的URL更干净、更语义化,一看就知道是在操作什么资源、做什么操作。

2.2 RESTful设计原则与最佳实践

下面是我在实际项目中总结的几条RESTful设计原则,照着做基本不会出大问题。

1. URL用名词,不用动词

URL表示资源,资源是名词。操作由HTTP方法决定。比如"获取用户列表"应该是GET /users,不是GET /getUsers。

2. 用HTTP方法表示操作

HTTP方法 操作 幂等 示例
GET 查询 GET /users/1
POST 新增 POST /users
PUT 全量更新 PUT /users/1
PATCH 部分更新 PATCH /users/1
DELETE 删除 DELETE /users/1

关于幂等性:同一个请求执行一次和执行多次,结果是一样的,就是幂等。GET、PUT、DELETE是幂等的,POST不是。这在重试机制、消息队列消费的时候很重要。

3. 层级关系用路径表达

资源之间有从属关系的,用路径层级表示。比如"某个用户的订单":

java 复制代码
GET /api/users/1/orders      # 获取用户1的所有订单
POST /api/users/1/orders     # 给用户1创建订单
GET /api/users/1/orders/100  # 获取用户1的订单100

4. 过滤、排序、分页用查询参数

这些不是资源本身,是对资源集合的操作,用查询参数:

java 复制代码
GET /api/users?page=1&size=10          # 分页
GET /api/users?name=张&age=25          # 条件过滤
GET /api/users?sort=age,desc           # 排序

5. 版本号放在URL或请求头

接口版本管理有两种常见做法:

java 复制代码
# URL路径版本(推荐,直观)
GET /api/v1/users
GET /api/v2/users

# 请求头版本
GET /api/users
Header: API-Version: 1

我个人倾向于URL路径版本,调试的时候一眼就能看到版本号,方便。

6. HTTP状态码要正确使用

不要所有接口都返回200然后在body里放code。HTTP状态码本身就有语义:

状态码 含义 使用场景
200 OK 成功 GET/PUT/PATCH成功
201 Created 创建成功 POST创建成功
204 No Content 无内容 DELETE成功
400 Bad Request 请求参数错误 参数校验失败
401 Unauthorized 未认证 未登录/token无效
403 Forbidden 无权限 登录了但没权限
404 Not Found 资源不存在 查不到数据
500 Internal Server Error 服务器错误 代码异常

当然,实际项目中很多团队还是统一返回200,用业务code区分。这个看团队规范,保持一致就行。后面讲全局异常处理的时候会给一套统一响应的方案。

三、参数校验:JSR380注解详解

3.1 JSR380与Bean Validation

参数校验是Web开发中绕不开的话题。最原始的写法是在Controller里一堆if判断:

java 复制代码
@PostMapping
public Result createUser(@RequestBody User user) {
    if (user.getName() == null || user.getName().trim().isEmpty()) {
        return Result.fail("用户名不能为空");
    }
    if (user.getAge() == null || user.getAge() < 0 || user.getAge() > 150) {
        return Result.fail("年龄不合法");
    }
    if (user.getEmail() == null || !user.getEmail().matches("^[a-zA-Z0-9_]+@[a-zA-Z0-9_]+\\.[a-zA-Z0-9_]+$")) {
        return Result.fail("邮箱格式不正确");
    }
    // ... 业务逻辑
}

这种写法的问题很明显:校验逻辑和业务逻辑混在一起、每个接口都要写一遍、代码冗余、维护困难。

JSR380就是来解决这个问题的。JSR380是Java EE的Bean Validation 2.0规范,Hibernate Validator是它的参考实现。Spring Boot已经默认集成了,只要引入spring-boot-starter-web,就自带了validation能力(Spring Boot 2.3之后需要单独引入starter)。

java 复制代码
<!-- Spring Boot 2.3+ 需要手动引入 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

用法很简单:在实体类字段上加校验注解,在Controller方法参数上加@Valid或@Validated注解,Spring会自动做校验。校验不通过会抛出MethodArgumentNotValidException,后面我们用全局异常处理统一捕获。

java 复制代码
@Data
public class UserDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 20, message = "用户名长度2-20个字符")
    private String name;

    @NotNull(message = "年龄不能为空")
    @Min(value = 0, message = "年龄不能小于0")
    @Max(value = 150, message = "年龄不能大于150")
    private Integer age;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}
java 复制代码
@PostMapping
public Result createUser(@Valid @RequestBody UserDTO userDTO) {
    // 直接写业务逻辑,不用管校验
    userService.create(userDTO);
    return Result.success();
}

干净多了。校验逻辑从Controller剥离到DTO上,代码职责更清晰。

3.2 常用校验注解一览

JSR380提供了很多校验注解,日常开发常用的就那十几个,整理一下:

空值校验

注解 说明 适用类型
@NotNull 不能为null 任意类型
@NotBlank 不能为null且不能全是空格 字符串
@NotEmpty 不能为null且长度/大小大于0 字符串、集合、数组
@Null 必须为null 任意类型

这几个容易搞混,记一下:@NotBlank只用于字符串,会去掉首尾空格再判断;@NotEmpty用于字符串、集合、数组,判断长度是否大于0;@NotNull就是单纯判断不为null。

数值校验

注解 说明
@Min(value) 最小值
@Max(value) 最大值
@DecimalMin(value) 最小值(支持小数)
@DecimalMax(value) 最大值(支持小数)
@Positive 正数
@PositiveOrZero 正数或零
@Negative 负数
@NegativeOrZero 负数或零
@Digits(integer, fraction) 整数位数和小数位数限制

3.3 分组校验与自定义校验

分组校验

实际开发中,同一个DTO可能在新增和修改场景下校验规则不一样。比如新增的时候id可以为空(数据库自增),修改的时候id必须不为空。这时候就需要分组校验。

用法很简单:定义两个分组接口,在注解上指定groups,Controller上用@Validated指定分组。

java 复制代码
@Data
public class UserDTO {
    @NotNull(message = "id不能为空", groups = UpdateGroup.class)
    @Null(message = "新增时id必须为空", groups = AddGroup.class)
    private Long id;

    @NotBlank(message = "用户名不能为空", groups = {AddGroup.class, UpdateGroup.class})
    @Size(min = 2, max = 20, message = "用户名长度2-20", groups = {AddGroup.class, UpdateGroup.class})
    private String name;

    @Email(message = "邮箱格式不正确", groups = {AddGroup.class, UpdateGroup.class})
    private String email;
}

自定义校验用得不多,但掌握了遇到复杂场景就能从容应对。比如"两个字段必须同时为空或同时不为空"这种跨字段校验,也可以用自定义校验+类级别注解实现。

**注意:**校验注解只能保证格式上的合法性,业务规则的校验(比如"用户名不能重复")还是要在Service层查数据库判断,不要试图用校验注解做业务校验。

四、全局异常处理:@RestControllerAdvice

4.1 为什么需要全局异常处理

没有全局异常处理的项目是什么样的?每个Controller里都有try-catch,有的地方抛异常,有的地方返回错误码,前端拿到的返回格式五花八门------有的是JSON,有的是HTML错误页,有的干脆就是堆栈信息。

问题很明显:

  • 代码冗余:每个接口都要写try-catch

  • 格式不统一:前端不知道怎么解析错误信息

  • 安全隐患:异常堆栈直接暴露给前端,泄露系统信息

  • 难以维护:异常处理散落在各处,改一个要找半天

Spring Boot提供了@RestControllerAdvice + @ExceptionHandler的方案,可以全局统一处理异常。配合统一的响应结果封装,前端拿到的所有返回格式都是一致的。

整体思路是这样的:

  1. 定义统一的响应结果类Result,包含code、message、data三个字段

  2. 定义业务异常类BusinessException,携带错误码和错误信息

  3. 用@RestControllerAdvice定义全局异常处理器,捕获各种异常,统一返回Result

  4. Controller里只负责正常逻辑,遇到业务问题直接throw BusinessException

4.2 统一响应结果封装

统一响应结果是前后端协作的基础。不管成功还是失败,前端拿到的JSON结构都是一样的,方便统一处理。

最经典的三段式结构:code(状态码)、message(提示信息)、data(数据)。

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
    /** 状态码:200成功,其他失败 */
    private Integer code;
    /** 提示信息 */
    private String message;
    /** 返回数据 */
    private T data;

    // 成功,无数据
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功", null);
    }

    // 成功,带数据
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }

    // 失败
    public static <T> Result<T> fail(Integer code, String message) {
        return new Result<>(code, message, null);
    }

    public static <T> Result<T> fail(String message) {
        return new Result<>(500, message, null);
    }
}

4.3 全局异常处理器实现

全局异常处理器的核心是@RestControllerAdvice和@ExceptionHandler。@RestControllerAdvice是@ControllerAdvice + @ResponseBody的组合,作用是增强所有@RestController,捕获异常后返回JSON。

java 复制代码
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        log.warn("业务异常:{}", e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    /**
     * 处理参数校验异常(@RequestBody)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
        // 取第一个错误信息
        String message = e.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(FieldError::getDefaultMessage)
                .orElse("参数校验失败");
        log.warn("参数校验失败:{}", message);
        return Result.fail(ErrorCode.PARAM_ERROR.getCode(), message);
    }

    /**
     * 处理参数校验异常(@RequestParam / @PathVariable)
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<Void> handleConstraintViolationException(ConstraintViolationException e) {
        String message = e.getConstraintViolations().stream()
                .findFirst()
                .map(ConstraintViolation::getMessage)
                .orElse("参数校验失败");
        log.warn("参数校验失败:{}", message);
        return Result.fail(ErrorCode.PARAM_ERROR.getCode(), message);
    }

    /**
     * 处理404等Servlet异常
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public Result<Void> handleNoHandlerFoundException(NoHandlerFoundException e) {
        log.warn("接口不存在:{}", e.getRequestURL());
        return Result.fail(ErrorCode.NOT_FOUND.getCode(), "接口不存在");
    }

    /**
     * 处理其他未知异常
     */
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常:", e);
        return Result.fail(ErrorCode.SYSTEM_ERROR.getCode(), "系统繁忙,请稍后重试");
    }
}

这里有几个要点:

  • MethodArgumentNotValidException:@RequestBody + @Valid 校验失败时抛出的异常

  • ConstraintViolationException:@RequestParam + @Validated 校验失败时抛出的异常,两种场景要分别处理

  • NoHandlerFoundException:404异常,默认Spring Boot不会抛出这个异常,需要在配置里开启

五、静态资源配置

5.1 默认静态资源映射

Spring Boot默认就配置好了静态资源映射,不用写任何代码就能直接用。默认会从以下几个目录加载静态资源:

  • classpath:/META-INF/resources/

  • classpath:/resources/

  • classpath:/static/

  • classpath:/public/

优先级从上到下。也就是说,把图片、CSS、JS放到src/main/resources/static/目录下,直接通过http://localhost:8080/xxx.jpg就能访问。

默认的静态资源映射路径是 /**,也就是所有请求都会先去静态资源目录找,找不到再走Controller。

可以通过application.yml修改默认配置:

java 复制代码
spring:
  web:
    resources:
      static-locations: classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/
  mvc:
    static-path-pattern: /**

六、Swagger/Knife4j接口文档集成

6.1 Knife4j依赖与配置

接口文档是前后端协作的桥梁。手写文档?维护成本太高,代码改了文档忘改是常态。Swagger可以根据代码自动生成接口文档,注解加一加,文档就出来了。

原生Swagger UI长得比较朴素,国内用得更多的是Knife4j------基于Swagger的增强UI,界面好看,功能也多,支持离线文档导出、参数缓存、接口搜索等。

引入依赖

java 复制代码
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
    <version>4.3.0</version>
</dependency>

Spring Boot 3.x用knife4j-openapi3-jakarta-spring-boot-starter,注解是Jakarta EE那套,用法基本一样。

配置类

java 复制代码
@Configuration
@EnableSwagger2WebMvc
public class SwaggerConfig {

    @Bean
    public Docket defaultApi2() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                // 指定扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.example.controller"))
                // 指定路径,any()表示所有
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("用户管理系统 API文档")
                .description("Spring Boot Web开发实战项目接口文档")
                .contact(new Contact("张三", "https://blog.csdn.net", "xxx@qq.com"))
                .version("1.0")
                .build();
    }
}

启动项目,访问 http://localhost:8080/doc.html 就能看到Knife4j的界面了。

生产环境关闭Swagger

生产环境一般要关闭Swagger,防止接口泄露。可以用@Profile注解或者配置文件控制:

6.2 常用Swagger注解

Swagger提供了一套注解用来描述接口,常用的就那几个:

Controller类上的注解

注解 说明 示例
@Api 描述Controller类 @Api(tags = "用户管理")
@ApiIgnore 忽略这个类/方法 @ApiIgnore

这样配完,Knife4j页面上就能看到完整的接口文档,包括接口说明、参数说明、响应示例,还能在线调试。

七、总结

这篇把Spring Boot Web开发的核心知识点串了一遍,从基础的Controller到参数校验、全局异常处理,再到静态资源配置和Swagger集成,最后给了一套完整的实战案例。

总结一下核心要点:

  1. Controller层要干净:只做参数接收和Service调用,不写业务逻辑,不写try-catch

  2. 参数校验用JSR380:校验注解写在DTO上,Controller加@Validated,代码清爽

  3. 异常统一处理:@RestControllerAdvice + @ExceptionHandler,所有异常一个地方搞定

  4. 响应格式统一:Result<T> 三段式,code/message/data,前端好处理

  5. 对象分层:Entity/DTO/VO各司其职,不要用一个对象从头用到尾

  6. 接口文档自动化:Knife4j + Swagger注解,代码即文档,不用手写

这套规范我在很多项目里都用过,小到个人项目大到企业级应用,都能hold住。规范这东西,前期多写几行代码,后期维护省十倍的力。

最后提一下Spring Boot 3.x的变化:JSR380变成了Jakarta Validation,包名从javax.validation换成了jakarta.validation,注解用法完全一样,就是import路径换一下。Swagger也要换成springdoc-openapi,注解是@Tag、@Operation那一套,思路差不多。新项目直接上3.x的话,注意一下这些变化就行。

下一篇打算聊聊Spring Boot的数据访问层,MyBatis-Plus的进阶用法,包括条件构造器、分页插件、自动填充、逻辑删除这些。感兴趣的可以关注一下。

相关推荐
完美火龙篇 四月的友1 小时前
SpringBoot 即时聊天 IM 完整实现(HTTP会话管理 \+ WebSocket实时推送 \+ 离线消息)
spring boot·websocket·http
倒流时光三十年1 小时前
第一阶段 02 · Mapping 与数据类型(text vs keyword 是重点)
后端·python·django
老王以为1 小时前
解剖 Claude Code:逆向工程视角下的入口架构分析
前端·ai编程·claude
Csvn1 小时前
💰 JavaScript 浮点数精度问题深度剖析——前端金额计算的「定时炸弹」
前端
Zane19941 小时前
JMM 与 happens-before:一次搞懂 Java 内存模型
java·后端
Csvn1 小时前
structuredClone:原生深拷贝 API 的正确打开方式
前端
用户298698530141 小时前
在线将 Word 文档转换为 TXT 格式:快速免费的方法
人工智能·后端
大猫会长1 小时前
获取favicon.ico的方法
前端·javascript·html
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 5 篇:事件处理与表单(受控组件 vs v-model)
前端·vue.js·react.js