前言
这是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的方案,可以全局统一处理异常。配合统一的响应结果封装,前端拿到的所有返回格式都是一致的。
整体思路是这样的:
-
定义统一的响应结果类Result,包含code、message、data三个字段
-
定义业务异常类BusinessException,携带错误码和错误信息
-
用@RestControllerAdvice定义全局异常处理器,捕获各种异常,统一返回Result
-
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集成,最后给了一套完整的实战案例。
总结一下核心要点:
-
Controller层要干净:只做参数接收和Service调用,不写业务逻辑,不写try-catch
-
参数校验用JSR380:校验注解写在DTO上,Controller加@Validated,代码清爽
-
异常统一处理:@RestControllerAdvice + @ExceptionHandler,所有异常一个地方搞定
-
响应格式统一:Result<T> 三段式,code/message/data,前端好处理
-
对象分层:Entity/DTO/VO各司其职,不要用一个对象从头用到尾
-
接口文档自动化: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的进阶用法,包括条件构造器、分页插件、自动填充、逻辑删除这些。感兴趣的可以关注一下。