Skip to content

参数绑定与数据校验

Spring MVC 提供了强大的参数绑定和数据校验机制,能够自动将 HTTP 请求参数转换为控制器方法的参数,并对这些参数进行验证。本文详细介绍这些功能的使用方法。

参数绑定基础

参数绑定是将 HTTP 请求中的数据映射到控制器方法参数的过程。Spring MVC 支持多种类型的参数绑定。

控制器方法支持的参数类型

Spring MVC 控制器方法可以接受多种参数类型:

  1. 基本数据类型和包装类:int、long、Integer、Long 等
  2. 字符串类型:String
  3. 模型相关:Model、ModelMap、Map
  4. Servlet API 类型:HttpServletRequest、HttpServletResponse、HttpSession 等
  5. 特殊类型:RedirectAttributes、Errors、BindingResult 等
  6. 自定义对象:POJO 类
  7. 集合类型:List、Set、Map 等

请求参数到方法参数的映射

@RequestParam

用于从请求参数中提取值,支持基本类型和字符串:

java
@GetMapping("/search")
public String search(
    @RequestParam String keyword,                      // 必需参数
    @RequestParam(required = false) String category,   // 可选参数
    @RequestParam(defaultValue = "1") int page,        // 带默认值的参数
    @RequestParam(name = "size") int pageSize,         // 参数名不匹配方法参数名
    @RequestParam List<String> tags,                   // 集合类型 (多值参数)
    Model model
) {
    // 处理逻辑
    return "searchResults";
}

@PathVariable

用于从 URL 路径中提取变量值:

java
@GetMapping("/users/{userId}/posts/{postId}")
public String getPost(
    @PathVariable Long userId,
    @PathVariable("postId") Long id,  // 当路径变量名与方法参数名不同时
    @PathVariable(required = false) Optional<String> version,  // 可选路径变量
    Model model
) {
    // 处理逻辑
    return "postDetail";
}

@RequestHeader

用于获取请求头信息:

java
@GetMapping("/greeting")
public String greeting(
    @RequestHeader("User-Agent") String userAgent,
    @RequestHeader(value = "Accept-Language", defaultValue = "en") String language,
    @RequestHeader Map<String, String> headers,  // 获取所有请求头
    Model model
) {
    // 处理逻辑
    return "greeting";
}

@CookieValue

用于获取 Cookie 值:

java
@GetMapping("/dashboard")
public String dashboard(
    @CookieValue(value = "sessionId", required = false) String sessionId,
    @CookieValue("JSESSIONID") Cookie cookie,  // 直接获取 Cookie 对象
    Model model
) {
    // 处理逻辑
    return "dashboard";
}

@RequestBody

用于接收 HTTP 请求体中的 JSON/XML 数据并转换为 Java 对象:

java
@PostMapping("/api/users")
@ResponseBody
public User createUser(@RequestBody User user) {
    // 处理逻辑
    return userService.save(user);
}

@ModelAttribute

用于将请求参数绑定到对象,或从模型中获取已有属性:

java
// 方法参数上的 @ModelAttribute
@PostMapping("/users/update")
public String updateUser(@ModelAttribute("user") User user) {
    userService.update(user);
    return "redirect:/users";
}

// 方法级别的 @ModelAttribute,在所有处理器方法执行前填充模型
@ModelAttribute("categories")
public List<Category> populateCategories() {
    return categoryService.findAll();
}

@SessionAttributes

在会话中存储模型属性:

java
@Controller
@SessionAttributes({"user", "shoppingCart"})
public class CheckoutController {
    
    @GetMapping("/checkout/address")
    public String address(Model model) {
        model.addAttribute("user", new User()); // 会存储在会话中
        return "checkout/address";
    }
    
    @PostMapping("/checkout/address")
    public String processAddress(@ModelAttribute("user") User user) {
        // user 来自会话
        return "redirect:/checkout/payment";
    }
}

@RequestPart

用于处理 multipart/form-data 请求,特别是文件上传:

java
@PostMapping("/upload")
public String handleFileUpload(
    @RequestPart("file") MultipartFile file,
    @RequestPart("userData") User user
) {
    // 处理上传文件和用户数据
    return "uploadSuccess";
}

数据绑定机制

Spring MVC 的数据绑定过程分为以下几个步骤:

  1. WebDataBinder 创建:为请求创建一个数据绑定器
  2. 数据类型转换:将请求参数(字符串)转换为目标类型
  3. 数据格式化:根据配置的格式化器格式化数据
  4. 数据校验:如果配置了验证器,执行数据校验

类型转换

内置转换器

Spring 内置了多种转换器,支持基本类型、集合等的转换:

  • StringToNumberConverterFactory:字符串到数字
  • StringToBooleanConverter:字符串到布尔值
  • StringToEnumConverterFactory:字符串到枚举
  • CollectionToCollectionConverter:集合之间的转换
  • StringToArrayConverter:字符串到数组
  • 等等

自定义转换器

可以实现 Converter 接口创建自定义转换器:

java
public class StringToDateConverter implements Converter<String, Date> {
    
    private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    
    @Override
    public Date convert(String source) {
        try {
            return dateFormat.parse(source);
        } catch (ParseException e) {
            throw new IllegalArgumentException("Invalid date format. Use yyyy-MM-dd");
        }
    }
}

// 注册转换器
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToDateConverter());
    }
}

数据格式化

内置格式化器

Spring 提供了多种格式化器:

  • NumberFormatter:数字格式化
  • CurrencyFormatter:货币格式化
  • PercentFormatter:百分比格式化
  • DateFormatter:日期格式化

使用注解配置格式化

可以使用以下注解配置字段格式:

java
public class Product {
    
    private String name;
    
    @NumberFormat(pattern = "#,###.##")
    private BigDecimal price;
    
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private Date releaseDate;
    
    // getters and setters
}

数据校验

Spring MVC 支持两种主要的验证方式:

  1. 基于 JSR-303/JSR-380 的 Bean Validation
  2. Spring 的 Validator 接口

Bean Validation

使用 Bean Validation 需要添加依赖(如 Hibernate Validator)并使用相关注解:

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

常用验证注解

java
public class User {
    
    @NotBlank(message = "姓名不能为空")
    private String name;
    
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄必须大于或等于18")
    @Max(value = 120, message = "年龄必须小于或等于120")
    private Integer age;
    
    @Past(message = "生日必须是过去的日期")
    private Date birthDate;
    
    @Size(min = 6, max = 20, message = "密码长度必须在6到20之间")
    @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).*$", 
             message = "密码必须包含数字、小写字母和大写字母")
    private String password;
    
    @Valid // 级联验证
    private Address address;
    
    // getters and setters
}

public class Address {
    
    @NotBlank(message = "街道不能为空")
    private String street;
    
    @NotBlank(message = "城市不能为空")
    private String city;
    
    @NotBlank(message = "邮政编码不能为空")
    @Pattern(regexp = "\\d{6}", message = "邮政编码必须是6位数字")
    private String zipCode;
    
    // getters and setters
}

在控制器中使用验证

java
@PostMapping("/users")
public String createUser(
    @Valid @ModelAttribute User user,
    BindingResult result,
    Model model
) {
    if (result.hasErrors()) {
        return "user/form"; // 返回表单页面,显示错误信息
    }
    
    userService.save(user);
    return "redirect:/users";
}

自定义验证注解

可以创建自定义验证注解:

java
@Documented
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatches {
    
    String message() default "密码不匹配";
    
    Class<?>[] groups() default {};
    
    Class<? extends Payload>[] payload() default {};
}

public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, Object> {
    
    @Override
    public void initialize(PasswordMatches constraintAnnotation) {
    }
    
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        UserRegistrationDto dto = (UserRegistrationDto) obj;
        return dto.getPassword().equals(dto.getConfirmPassword());
    }
}

@PasswordMatches
public class UserRegistrationDto {
    // 字段和方法
}

分组验证

可以定义验证组以在不同场景使用不同的验证规则:

java
// 定义验证组
public interface Create {}
public interface Update {}

public class User {
    
    @NotNull(groups = Update.class)
    private Long id;
    
    @NotBlank(groups = {Create.class, Update.class})
    private String name;
    
    @NotBlank(groups = Create.class)
    @Null(groups = Update.class)
    private String password;
    
    // getters and setters
}

// 在控制器中使用
@PostMapping("/users")
public String createUser(
    @Validated(Create.class) @ModelAttribute User user,
    BindingResult result
) {
    // ...
}

@PutMapping("/users/{id}")
public String updateUser(
    @PathVariable Long id,
    @Validated(Update.class) @ModelAttribute User user,
    BindingResult result
) {
    // ...
}

Spring Validator 接口

除了 Bean Validation,还可以实现 Spring 的 Validator 接口:

java
public class UserValidator implements Validator {
    
    @Override
    public boolean supports(Class<?> clazz) {
        return User.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors) {
        User user = (User) target;
        
        if (user.getName() == null || user.getName().trim().isEmpty()) {
            errors.rejectValue("name", "name.empty", "Name is required");
        }
        
        if (user.getEmail() == null || !user.getEmail().contains("@")) {
            errors.rejectValue("email", "email.invalid", "Invalid email format");
        }
    }
}

// 在控制器中使用
@Controller
public class UserController {
    
    private final UserValidator userValidator;
    
    @Autowired
    public UserController(UserValidator userValidator) {
        this.userValidator = userValidator;
    }
    
    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.addValidators(userValidator);
    }
    
    @PostMapping("/users")
    public String createUser(@ModelAttribute User user, BindingResult result) {
        // 此处不需要显式调用验证器,因为已通过 @InitBinder 注册
        if (result.hasErrors()) {
            return "user/form";
        }
        
        return "redirect:/users";
    }
}

错误处理与显示

在控制器中处理错误

java
@PostMapping("/users")
public String createUser(@Valid @ModelAttribute User user, BindingResult result) {
    if (result.hasErrors()) {
        // 获取所有错误
        List<ObjectError> globalErrors = result.getGlobalErrors();
        List<FieldError> fieldErrors = result.getFieldErrors();
        
        // 获取特定字段的错误
        FieldError emailError = result.getFieldError("email");
        if (emailError != null) {
            String errorMessage = emailError.getDefaultMessage();
            // 处理特定字段错误
        }
        
        // 手动添加错误
        result.rejectValue("username", "username.exists", "用户名已存在");
        result.reject("form.global.error", "表单包含错误");
        
        return "user/form";
    }
    
    userService.save(user);
    return "redirect:/users";
}

在视图中显示错误(Thymeleaf)

html
<form th:action="@{/users}" th:object="${user}" method="post">
    <!-- 显示全局错误 -->
    <div th:if="${#fields.hasGlobalErrors()}">
        <p th:each="err : ${#fields.globalErrors()}" th:text="${err}" class="error"></p>
    </div>
    
    <!-- 名称字段 -->
    <div>
        <label for="name">姓名:</label>
        <input type="text" id="name" th:field="*{name}" />
        <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span>
    </div>
    
    <!-- 邮箱字段 -->
    <div>
        <label for="email">邮箱:</label>
        <input type="text" id="email" th:field="*{email}" />
        <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error"></span>
    </div>
    
    <!-- 年龄字段 -->
    <div>
        <label for="age">年龄:</label>
        <input type="number" id="age" th:field="*{age}" />
        <span th:if="${#fields.hasErrors('age')}" th:errors="*{age}" class="error"></span>
    </div>
    
    <button type="submit">提交</button>
</form>

在 JSP 中显示错误

jsp
<form:form modelAttribute="user" method="post">
    <!-- 显示全局错误 -->
    <form:errors path="*" cssClass="error" element="div" />
    
    <!-- 名称字段 -->
    <div>
        <label for="name">姓名:</label>
        <form:input path="name" id="name" />
        <form:errors path="name" cssClass="error" />
    </div>
    
    <!-- 邮箱字段 -->
    <div>
        <label for="email">邮箱:</label>
        <form:input path="email" id="email" />
        <form:errors path="email" cssClass="error" />
    </div>
    
    <!-- 年龄字段 -->
    <div>
        <label for="age">年龄:</label>
        <form:input path="age" id="age" type="number" />
        <form:errors path="age" cssClass="error" />
    </div>
    
    <button type="submit">提交</button>
</form:form>

实战示例

完整的表单处理示例

java
@Controller
@RequestMapping("/products")
public class ProductController {
    
    private final ProductService productService;
    private final CategoryService categoryService;
    
    @Autowired
    public ProductController(ProductService productService, CategoryService categoryService) {
        this.productService = productService;
        this.categoryService = categoryService;
    }
    
    // 提供所有控制器方法共享的数据
    @ModelAttribute("categories")
    public List<Category> populateCategories() {
        return categoryService.findAll();
    }
    
    // 显示创建表单
    @GetMapping("/new")
    public String showCreateForm(Model model) {
        model.addAttribute("product", new ProductDto());
        return "product/form";
    }
    
    // 处理表单提交
    @PostMapping
    public String createProduct(
            @Valid @ModelAttribute("product") ProductDto productDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        // 自定义验证逻辑
        if (productDto.getPrice() != null && productDto.getDiscountPrice() != null
                && productDto.getDiscountPrice().compareTo(productDto.getPrice()) > 0) {
            result.rejectValue("discountPrice", "discountPrice.invalid", 
                    "折扣价不能高于原价");
        }
        
        if (result.hasErrors()) {
            return "product/form";
        }
        
        // 保存产品
        Product savedProduct = productService.save(convertToEntity(productDto));
        
        // 添加成功消息
        redirectAttributes.addFlashAttribute("message", "产品创建成功!");
        redirectAttributes.addAttribute("id", savedProduct.getId());
        
        return "redirect:/products/{id}";
    }
    
    // 显示编辑表单
    @GetMapping("/{id}/edit")
    public String showEditForm(@PathVariable Long id, Model model) {
        Product product = productService.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
        
        model.addAttribute("product", convertToDto(product));
        return "product/form";
    }
    
    // 处理更新请求
    @PostMapping("/{id}")
    public String updateProduct(
            @PathVariable Long id,
            @Valid @ModelAttribute("product") ProductDto productDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (!productService.existsById(id)) {
            throw new ResourceNotFoundException("Product not found");
        }
        
        if (result.hasErrors()) {
            return "product/form";
        }
        
        productDto.setId(id);
        productService.save(convertToEntity(productDto));
        
        redirectAttributes.addFlashAttribute("message", "产品更新成功!");
        return "redirect:/products/{id}";
    }
    
    // Helper methods for DTO conversion
    private ProductDto convertToDto(Product product) {
        ProductDto dto = new ProductDto();
        dto.setId(product.getId());
        dto.setName(product.getName());
        dto.setDescription(product.getDescription());
        dto.setPrice(product.getPrice());
        dto.setDiscountPrice(product.getDiscountPrice());
        dto.setCategoryId(product.getCategory().getId());
        dto.setAvailable(product.isAvailable());
        return dto;
    }
    
    private Product convertToEntity(ProductDto dto) {
        Product product = new Product();
        if (dto.getId() != null) {
            product = productService.findById(dto.getId())
                    .orElse(new Product());
        }
        
        product.setName(dto.getName());
        product.setDescription(dto.getDescription());
        product.setPrice(dto.getPrice());
        product.setDiscountPrice(dto.getDiscountPrice());
        product.setAvailable(dto.isAvailable());
        
        if (dto.getCategoryId() != null) {
            Category category = categoryService.findById(dto.getCategoryId())
                    .orElseThrow(() -> new ResourceNotFoundException("Category not found"));
            product.setCategory(category);
        }
        
        return product;
    }
}

@Data
public class ProductDto {
    
    private Long id;
    
    @NotBlank(message = "产品名称不能为空")
    @Size(min = 2, max = 100, message = "产品名称长度必须在2-100个字符之间")
    private String name;
    
    @Size(max = 500, message = "产品描述不能超过500个字符")
    private String description;
    
    @NotNull(message = "价格不能为空")
    @DecimalMin(value = "0.01", message = "价格必须大于0")
    private BigDecimal price;
    
    private BigDecimal discountPrice;
    
    @NotNull(message = "分类不能为空")
    private Long categoryId;
    
    private boolean available = true;
}

高级表单示例(多对多关系)

java
@Controller
@RequestMapping("/courses")
public class CourseController {
    
    private final CourseService courseService;
    private final TeacherService teacherService;
    
    @Autowired
    public CourseController(CourseService courseService, TeacherService teacherService) {
        this.courseService = courseService;
        this.teacherService = teacherService;
    }
    
    @ModelAttribute("allTeachers")
    public List<Teacher> populateTeachers() {
        return teacherService.findAll();
    }
    
    @GetMapping("/new")
    public String showCreateForm(Model model) {
        CourseDto courseDto = new CourseDto();
        model.addAttribute("course", courseDto);
        return "course/form";
    }
    
    @PostMapping
    public String createCourse(
            @Valid @ModelAttribute("course") CourseDto courseDto,
            BindingResult result,
            RedirectAttributes redirectAttributes) {
        
        if (result.hasErrors()) {
            return "course/form";
        }
        
        Course savedCourse = courseService.save(convertToEntity(courseDto));
        redirectAttributes.addFlashAttribute("message", "课程创建成功!");
        
        return "redirect:/courses";
    }
    
    private Course convertToEntity(CourseDto dto) {
        Course course = new Course();
        course.setTitle(dto.getTitle());
        course.setDescription(dto.getDescription());
        course.setCredits(dto.getCredits());
        
        if (dto.getTeacherIds() != null && !dto.getTeacherIds().isEmpty()) {
            Set<Teacher> teachers = dto.getTeacherIds().stream()
                    .map(id -> teacherService.findById(id)
                            .orElseThrow(() -> new ResourceNotFoundException("Teacher not found")))
                    .collect(Collectors.toSet());
            course.setTeachers(teachers);
        }
        
        return course;
    }
}

@Data
public class CourseDto {
    
    private Long id;
    
    @NotBlank(message = "课程标题不能为空")
    @Size(min = 3, max = 200, message = "课程标题长度必须在3-200个字符之间")
    private String title;
    
    @Size(max = 1000, message = "课程描述不能超过1000个字符")
    private String description;
    
    @NotNull(message = "学分不能为空")
    @Min(value = 1, message = "学分必须大于等于1")
    @Max(value = 10, message = "学分不能超过10")
    private Integer credits;
    
    @NotEmpty(message = "请至少选择一位教师")
    private List<Long> teacherIds;
}

在 Thymeleaf 模板中展示多选表单字段:

html
<form th:action="@{/courses}" th:object="${course}" method="post">
    <!-- 其他字段 -->
    
    <div>
        <label>教师:</label>
        <select th:field="*{teacherIds}" multiple>
            <option th:each="teacher : ${allTeachers}"
                    th:value="${teacher.id}"
                    th:text="${teacher.name}">教师姓名</option>
        </select>
        <span th:if="${#fields.hasErrors('teacherIds')}" th:errors="*{teacherIds}" class="error"></span>
    </div>
    
    <!-- 其他字段 -->
    
    <button type="submit">保存</button>
</form>