Skip to content

第58课:Spring Boot Web 开发

🎯 学习目标

  • 掌握 RESTful API 设计规范
  • 掌握参数接收的各种方式
  • 掌握参数校验
  • 实现统一响应格式
  • 实现全局异常处理
  • 掌握跨域配置

📖 一、RESTful API 设计

1. RESTful 规范

HTTP 方法操作示例说明
GET查询GET /users查询所有用户
GET查询GET /users/1查询ID为1的用户
POST创建POST /users创建新用户
PUT更新PUT /users/1完全更新用户1
PATCH更新PATCH /users/1部分更新用户1
DELETE删除DELETE /users/1删除用户1

2. 完整的 RESTful Controller

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    // 查询所有用户:GET /api/users
    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }
    
    // 分页查询:GET /api/users?page=1&size=10
    @GetMapping
    public Page<User> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size
    ) {
        return userService.findAll(PageRequest.of(page, size));
    }
    
    // 根据ID查询:GET /api/users/1
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User not found"));
    }
    
    // 创建用户:POST /api/users
    @PostMapping
    public User createUser(@RequestBody @Valid User user) {
        return userService.save(user);
    }
    
    // 更新用户:PUT /api/users/1
    @PutMapping("/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody @Valid User user) {
        return userService.update(id, user);
    }
    
    // 部分更新:PATCH /api/users/1
    @PatchMapping("/{id}")
    public User patchUser(@PathVariable Long id, @RequestBody Map<String, Object> updates) {
        return userService.patch(id, updates);
    }
    
    // 删除用户:DELETE /api/users/1
    @DeleteMapping("/{id}")
    public void deleteUser(@PathVariable Long id) {
        userService.deleteById(id);
    }
    
    // 搜索用户:GET /api/users/search?keyword=Alice
    @GetMapping("/search")
    public List<User> searchUsers(@RequestParam String keyword) {
        return userService.search(keyword);
    }
}

📖 二、参数接收

1. @RequestParam - 查询参数

java
// GET /api/users?name=Alice&age=25
@GetMapping("/users")
public String getUser(
    @RequestParam String name,                      // 必填
    @RequestParam(required = false) Integer age,    // 可选
    @RequestParam(defaultValue = "0") int page      // 默认值
) {
    return "name=" + name + ", age=" + age + ", page=" + page;
}

// 接收多个同名参数:GET /api/users?id=1&id=2&id=3
@GetMapping("/users")
public List<User> getUsers(@RequestParam List<Long> id) {
    return userService.findByIds(id);
}

2. @PathVariable - 路径变量

java
// GET /api/users/123
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id);
}

// 多个路径变量:GET /api/posts/5/comments/10
@GetMapping("/posts/{postId}/comments/{commentId}")
public Comment getComment(
    @PathVariable Long postId,
    @PathVariable Long commentId
) {
    return commentService.find(postId, commentId);
}

// 正则约束:GET /api/users/123(只接受数字)
@GetMapping("/users/{id:[0-9]+}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id);
}

3. @RequestBody - 请求体

java
// POST /api/users
// Content-Type: application/json
// Body: {"name":"Alice","age":25}
@PostMapping("/users")
public User createUser(@RequestBody User user) {
    return userService.save(user);
}

// 使用 DTO 接收
@PostMapping("/users")
public User createUser(@RequestBody UserDTO dto) {
    User user = new User();
    BeanUtils.copyProperties(dto, user);
    return userService.save(user);
}

4. @RequestHeader - 请求头

java
@GetMapping("/info")
public String getInfo(
    @RequestHeader("User-Agent") String userAgent,
    @RequestHeader(value = "Authorization", required = false) String token
) {
    return "User-Agent: " + userAgent;
}
java
@GetMapping("/welcome")
public String welcome(@CookieValue("sessionId") String sessionId) {
    return "Session ID: " + sessionId;
}

6. 对象接收(自动绑定)

java
// GET /api/search?name=Alice&age=25&email=alice@example.com
@GetMapping("/search")
public List<User> search(UserSearchDTO dto) {
    // Spring 自动将参数绑定到 DTO 的属性
    return userService.search(dto);
}

@Data
public class UserSearchDTO {
    private String name;
    private Integer age;
    private String email;
}

📖 三、参数校验

1. 添加依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2. 实体类添加校验注解

java
import jakarta.validation.constraints.*;

@Data
public class UserDTO {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$",
             message = "密码必须至少8位,包含大小写字母和数字")
    private String password;
    
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @Min(value = 0, message = "年龄不能小于0")
    @Max(value = 150, message = "年龄不能大于150")
    private Integer age;
    
    @NotNull(message = "性别不能为空")
    private Gender gender;
    
    @Past(message = "生日必须是过去的日期")
    private LocalDate birthday;
    
    @DecimalMin(value = "0.0", message = "金额不能为负")
    private BigDecimal amount;
    
    @Size(max = 11, message = "手机号长度不能超过11位")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
}

3. Controller 使用 @Valid

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    // @Valid 触发校验
    @PostMapping
    public User createUser(@RequestBody @Valid UserDTO dto) {
        return userService.create(dto);
    }
    
    // @Validated 支持分组校验
    @PutMapping("/{id}")
    public User updateUser(
        @PathVariable Long id,
        @RequestBody @Validated(Update.class) UserDTO dto
    ) {
        return userService.update(id, dto);
    }
}

4. 常用校验注解

注解说明示例
@NotNull不能为 null@NotNull private String name;
@NotEmpty不能为 null 且长度>0@NotEmpty private List<String> tags;
@NotBlank不能为 null 且去除空格后长度>0@NotBlank private String username;
@Size长度范围@Size(min=3, max=20) private String name;
@Min/@Max数值范围@Min(0) @Max(150) private Integer age;
@Email邮箱格式@Email private String email;
@Pattern正则匹配@Pattern(regexp="^1[3-9]\d{9}$") private String phone;
@Past/@Future过去/将来的日期@Past private LocalDate birthday;
@AssertTrue/@AssertFalse布尔值@AssertTrue private boolean agreed;

5. 自定义校验注解

java
// 1. 定义注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 2. 实现校验器
public class PhoneValidator implements ConstraintValidator<Phone, String> {
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;  // 空值由 @NotBlank 处理
        }
        return value.matches("^1[3-9]\\d{9}$");
    }
}

// 3. 使用
@Data
public class UserDTO {
    @Phone
    private String phone;
}

📖 四、统一响应格式

1. 响应结果类

java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
    private Integer code;      // 状态码
    private String message;    // 消息
    private T data;            // 数据
    private Long timestamp;    // 时间戳
    
    // 成功响应
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "success", data, System.currentTimeMillis());
    }
    
    public static <T> Result<T> success() {
        return success(null);
    }
    
    // 失败响应
    public static <T> Result<T> error(Integer code, String message) {
        return new Result<>(code, message, null, System.currentTimeMillis());
    }
    
    public static <T> Result<T> error(String message) {
        return error(500, message);
    }
}

2. Controller 使用

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return Result.success(user);
    }
    
    @PostMapping
    public Result<User> createUser(@RequestBody @Valid UserDTO dto) {
        User user = userService.create(dto);
        return Result.success(user);
    }
    
    @DeleteMapping("/{id}")
    public Result<Void> deleteUser(@PathVariable Long id) {
        userService.deleteById(id);
        return Result.success();
    }
}

3. 响应示例

json
// 成功响应
{
  "code": 200,
  "message": "success",
  "data": {
    "id": 1,
    "username": "alice",
    "email": "alice@example.com"
  },
  "timestamp": 1701234567890
}

// 失败响应
{
  "code": 400,
  "message": "用户名已存在",
  "data": null,
  "timestamp": 1701234567890
}

📖 五、全局异常处理

1. 自定义异常

java
// 业务异常
public class BusinessException extends RuntimeException {
    private Integer code;
    
    public BusinessException(String message) {
        super(message);
        this.code = 400;
    }
    
    public BusinessException(Integer code, String message) {
        super(message);
        this.code = code;
    }
    
    public Integer getCode() {
        return code;
    }
}

// 资源未找到异常
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

2. 全局异常处理器

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    // 处理业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        return Result.error(e.getCode(), e.getMessage());
    }
    
    // 处理资源未找到
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Result<Void> handleResourceNotFoundException(ResourceNotFoundException e) {
        return Result.error(404, e.getMessage());
    }
    
    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Map<String, String>> handleValidationException(
        MethodArgumentNotValidException e
    ) {
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> {
            errors.put(error.getField(), error.getDefaultMessage());
        });
        return Result.error(400, "参数校验失败").setData(errors);
    }
    
    // 处理约束违反异常
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<Void> handleConstraintViolationException(
        ConstraintViolationException e
    ) {
        return Result.error(400, e.getMessage());
    }
    
    // 处理所有未捕获的异常
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常:", e);
        return Result.error(500, "系统繁忙,请稍后再试");
    }
}

3. 使用示例

java
@Service
public class UserService {
    
    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("用户不存在"));
    }
    
    public User create(UserDTO dto) {
        if (userRepository.existsByUsername(dto.getUsername())) {
            throw new BusinessException("用户名已存在");
        }
        // ...
    }
}

📖 六、跨域配置

方式1:全局配置

java
@Configuration
public class CorsConfig {
    
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")                    // 所有路径
                        .allowedOrigins("http://localhost:3000")  // 允许的源
                        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                        .allowedHeaders("*")                  // 允许的请求头
                        .allowCredentials(true)               // 允许携带凭证
                        .maxAge(3600);                        // 预检请求缓存时间
            }
        };
    }
}

方式2:注解配置

java
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:3000")  // 允许该源跨域
public class UserController {
    // ...
}

📖 七、文件上传下载

1. 文件上传

java
@RestController
@RequestMapping("/api/files")
public class FileController {
    
    @Value("${file.upload.path}")
    private String uploadPath;
    
    // 单文件上传
    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            throw new BusinessException("文件不能为空");
        }
        
        // 生成唯一文件名
        String originalFilename = file.getOriginalFilename();
        String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        String filename = UUID.randomUUID().toString() + extension;
        
        // 保存文件
        Path path = Paths.get(uploadPath, filename);
        Files.createDirectories(path.getParent());
        file.transferTo(path.toFile());
        
        return Result.success(filename);
    }
    
    // 多文件上传
    @PostMapping("/uploads")
    public Result<List<String>> uploads(@RequestParam("files") MultipartFile[] files) {
        List<String> filenames = new ArrayList<>();
        for (MultipartFile file : files) {
            // 保存文件...
            filenames.add(filename);
        }
        return Result.success(filenames);
    }
}

2. 文件下载

java
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) {
    Path path = Paths.get(uploadPath, filename);
    Resource resource = new UrlResource(path.toUri());
    
    if (!resource.exists()) {
        throw new ResourceNotFoundException("文件不存在");
    }
    
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, 
                   "attachment; filename=\"" + filename + "\"")
            .body(resource);
}

3. 配置文件大小限制

yaml
spring:
  servlet:
    multipart:
      max-file-size: 10MB      # 单文件最大大小
      max-request-size: 50MB   # 请求最大大小

💡 最佳实践

  1. 使用 DTO 模式(Controller 不直接使用 Entity)
  2. 统一响应格式(便于前端处理)
  3. 全局异常处理(避免代码中到处 try-catch)
  4. 参数校验(使用 @Valid,不要手动校验)
  5. RESTful 规范(资源命名用复数,动词用 HTTP 方法)

⚠️ 常见陷阱

1. Controller 直接返回 Entity

Entity 可能包含密码、内部状态、懒加载关联和数据库字段,直接返回会造成安全和维护问题。

2. 统一响应滥用

并不是所有响应都必须强行包一层。文件下载、流式响应、健康检查等场景可以返回原生 ResponseEntity

3. 异常处理吞掉真实错误

全局异常处理应该记录未知异常堆栈,但返回给前端时隐藏内部细节。

4. 文件上传信任原始文件名

MultipartFile.getOriginalFilename() 来自客户端,不能直接作为服务器保存路径,必须重命名并防路径穿越。

5. RESTful 只学 URL,不学语义

RESTful 不只是路径写成 /users,还包括正确使用 HTTP 方法、状态码、幂等性和资源建模。


🆚 Java vs C 对比

维度C Web 服务Spring Boot Web
路由手动注册或框架路由注解映射
请求体解析手动调用 JSON 库@RequestBody 自动绑定
参数校验手写 ifBean Validation
错误响应手动组织@RestControllerAdvice
文件上传手动解析 multipartMultipartFile
API 风格取决于框架REST Controller 标准化

Spring Boot Web 的优势是把 HTTP、JSON、校验、异常、文件上传等常见 Web 能力集成到统一开发模型中。


📝 练习

完成 练习/Ex58_SpringBoot_Web.java

  1. 实现完整的 CRUD API
  2. 参数校验
  3. 统一响应和异常处理
  4. 文件上传下载
  5. 分页查询
  6. 综合:博客系统 API

🎓 下一步

  • 第59课:Spring Data JPA - 数据库访问、关系映射