本文正在参加「金石计划」
前言
参数验证很重要,是平时开发环节中不可少的一部分,但是我想很多后端同事会偷懒,干脆不错,这样很可能给系统的稳定性和安全性带来严重的危害。那么在Spring Boot应用中如何做好参数校验工作呢,本文提供了10个小技巧,你知道几个呢?
1.使用验证注解
Spring Boot
提供了内置的验证注解,可以帮助简单、快速地对输入字段进行验证,例如检查 null 或空字段、强制执行长度限制、使用正则表达式验证模式以及验证电子邮件地址。
一些最常用的验证注释包括:
@NotNull
:指定字段不能为空。@NotEmpty
:指定列表字段不能为空。@NotBlank
:指定字符串字段不得为空或仅包含空格。@Min
和@Max
:指定数字字段的最小值和最大值。@Pattern
:指定字符串字段必须匹配的正则表达式模式。@Email
:指定字符串字段必须是有效的电子邮件地址。
具体用法参考下面例子:
public class User {
@NotNull
private Long id;
@NotBlank
@Size(min = 2, max = 50)
private String firstName;
@NotBlank
@Size(min = 2, max = 50)
private String lastName;
@Email
private String email;
@NotNull
@Min(18)
@Max(99)
private Integer age;
@NotEmpty
private List<String> hobbies;
@Pattern(regexp = "[A-Z]{2}\d{4}")
private String employeeId;
2 使用自定义验证注解
虽然 Spring Boot 的内置验证注释很有用,但它们可能无法涵盖所有情况。如果有特殊参数验证的场景,可以使用 Spring 的 JSR 303 验证框架创建自定义验证注释。自定义注解可以让你的的验证逻辑更具可重用性和可维护性。
假设我们有一个应用程序,用户可以在其中创建帖子。每个帖子都应该有一个标题和一个正文,并且标题在所有帖子中应该是唯一的。虽然 Spring Boot 提供了用于检查字段是否为空的内置验证注释,但它没有提供用于检查唯一性的内置验证注释。在这种情况下,我们可以创建一个自定义验证注解来处理这种情况。
首先,我们创建自定义约束注解UniqueTitle
:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueTitleValidator.class)
public @interface UniqueTitle {
String message() default "Title must be unique";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
接下来,我们创建一个PostRepository
接口,目的是从数据库中检索帖子:
public interface PostRepository extends JpaRepository<Post, Long> {
Post findByTitle(String title);
}
然后我们需要定义验证器类 UniqueTitleValidator
,如下所示:
@Component
public class UniqueTitleValidator implements ConstraintValidator<UniqueTitle, String> {
@Autowired
private PostRepository postRepository;
@Override
public boolean isValid(String title, ConstraintValidatorContext context) {
if (title == null) {
return true;
}
return Objects.isNull(postRepository.findByTitle(title));
}
}
UniqueTitleValidator
实现了ConstraintValidator
接口,它有两个泛型类型:第一个是自定义注解UniqueTitle
,第二个是正在验证的字段类型(在本例中为String
). 我们还自动装配了PostRepository
类以从数据库中检索帖子。
isValid()
方法通过查询 PostRepository
来检查 title
是否为 null 或者它是否是唯一的。如果 title
为 null 或唯一,则验证成功,并返回 true。
定义了自定义验证注释和验证器类后,我们现在可以使用它来验证 Spring Boot 应用程序中的帖子标题:
public class Post {
@UniqueTitle
private String title;
@NotNull
private String body;
}
我们已将 @UniqueTitle
注释应用于 Post
类中的 title
变量。验证此字段时,这将触发 UniqueTitleValidator
类中定义的验证逻辑。
3 在服务器端验证
除了前端或者客户端做了验证意外,服务器端验证输入是至关重要的。它可以确保在处理或存储任何恶意或格式错误的数据之前将其捕获,这对于应用程序的安全性和稳定性至关重要。
假设我们有一个允许用户创建新帐户的 REST
端点。端点需要一个包含用户用户名和密码的 JSON 请求体。为确保输入有效,我们可以创建一个 DTO(数据传输对象)类并将验证注释应用于其字段:
public class UserDTO {
@NotBlank
private String username;
@NotBlank
private String password;
}
我们使用@NotBlank
注解来确保username
和password
字段不为空或 null。
接下来,我们可以创建一个控制器方法来处理 HTTP POST 请求并在创建新用户之前验证输入:
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public ResponseEntity<String> createUser(@Valid @RequestBody UserDTO userDto) {
userService.createUser(userDto);
return ResponseEntity.status(HttpStatus.CREATED).body("User created successfully");
}
}
我们使用 Spring 的@Validated
注解来启用方法级验证,我们还将 @Valid
注释应用于 userDto
参数以触发验证过程。
4 提供有意义的错误信息
当验证失败时,必须提供清晰简洁的错误消息来描述出了什么问题以及如何修复它。
这是一个示例,如果我们有一个允许用户创建新用户的 RESTful API
。我们要确保姓名和电子邮件地址字段不为空,年龄在 18 到 99 岁之间,除了这些字段,如果用户尝试使用重复的“用户名”创建帐户,我们还会提供明确的错误消息或“电子邮件”。
为此,我们可以定义一个带有必要验证注释的模型类 User,如下所示:
public class User {
@NotBlank(message = "用户名不能为空")
private String name;
@NotBlank(message = "Email不能为空")
@Email(message = "无效的Emaild地址")
private String email;
@NotNull(message = "年龄不能为空")
@Min(value = 18, message = "年龄必须大于18")
@Max(value = 99, message = "年龄必须小于99")
private Integer age;
}
- 我们使用 message属性为每个验证注释提供了自定义错误消息。
接下来,在我们的 Spring 控制器中,我们可以处理表单提交并使用 @Valid
注释验证用户输入:
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@PostMapping
public ResponseEntity<String> createUser(@Valid @RequestBody User user, BindingResult result) {
if (result.hasErrors()) {
List<String> errorMessages = result.getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(errorMessages.toString());
}
// save the user to the database using UserService
userService.saveUser(user);
return ResponseEntity.status(HttpStatus.CREATED).body("User created successfully");
}
}
- 我们使用
@Valid
注释来触发User
对象的验证,并使用BindingResult
对象来捕获任何验证错误。
5 将 i18n 用于错误消息
如果你的应用程序支持多种语言,则必须使用国际化 (i18n) 以用户首选语言显示错误消息。
以下是在 Spring Boot 应用程序中使用 i18n 处理错误消息的示例
- 首先,在资源目录下创建一个包含默认错误消息的
messages.properties
文件
# messages.properties
user.name.required=Name is required.
user.email.invalid=Invalid email format.
user.age.invalid=Age must be a number between 18 and 99.
- 接下来,为每种支持的语言创建一个
messages_xx.properties
文件,例如,中文的messages_zh_CN.properties
。
user.name.required=名称不能为空.
user.email.invalid=无效的email格式.
user.age.invalid=年龄必须在18到99岁之间.
- 然后,更新您的验证注释以使用本地化的错误消息
public class User {
@NotNull(message = "{user.id.required}")
private Long id;
@NotBlank(message = "{user.name.required}")
private String name;
@Email(message = "{user.email.invalid}")
private String email;
@NotNull(message = "{user.age.required}")
@Min(value = 18, message = "{user.age.invalid}")
@Max(value = 99, message = "{user.age.invalid}")
private Integer age;
}
- 最后,在 Spring 配置文件中配置
MessageSource bean
以加载i18n
消息文件
@Configuration
public class AppConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
@Bean
public LocalValidatorFactoryBean validator() {
LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
validatorFactoryBean.setValidationMessageSource(messageSource());
return validatorFactoryBean;
}
}
- 现在,当发生验证错误时,错误消息将根据随请求发送的“Accept-Language”标头以用户的首选语言显示。
6 使用分组验证
验证组是 Spring Boot 验证框架的一个强大功能,允许您根据其他输入值或应用程序状态应用条件验证规则。
现在有一个包含三个字段的User
类的情况下:firstName
、lastName
和email
。我们要确保如果 email
字段为空,则 firstName
或 lastName
字段必须非空。否则,所有三个字段都应该正常验证。
为此,我们将定义两个验证组:EmailNotEmpty
和 Default
。EmailNotEmpty
组将包含当 email
字段不为空时的验证规则,而 Default
组将包含所有三个字段的正常验证规则。
- 创建带有验证组的
User
类
public class User {
@NotBlank(groups = Default.class)
private String firstName;
@NotBlank(groups = Default.class)
private String lastName;
@Email(groups = EmailNotEmpty.class)
private String email;
// getters and setters omitted for brevity
public interface EmailNotEmpty {}
public interface Default {}
}
- 请注意,我们在
User
类中定义了两个接口,EmailNotEmpty
和Default
。这些将作为我们的验证组。
- 接下来,我们更新
Controller
使用这些验证组
@RestController
@RequestMapping("/users")
@Validated
public class UserController {
public ResponseEntity<String> createUser(
@Validated({org.example.model.ex6.User.EmailNotEmpty.class}) @RequestBody User userWithEmail,
@Validated({User.Default.class}) @RequestBody User userWithoutEmail)
{
// Create the user and return a success response
}
}
- 我们已将
@Validated
注释添加到我们的控制器,表明我们想要使用验证组。我们还更新了createUser
方法,将两个User
对象作为输入,一个在email
字段不为空时使用,另一个在它为空时使用。 @Validated
注释用于指定将哪个验证组应用于每个User
对象。对于userWithEmail
参数,我们指定了EmailNotEmpty
组,而对于userWithoutEmail
参数,我们指定了Default
组。
- 进行这些更改后,现在将根据“电子邮件”字段是否为空对“用户”类进行不同的验证。如果为空,则
firstName
或lastName
字段必须非空。否则,所有三个字段都将正常验证。
7 对复杂逻辑使用跨域验证
如果需要验证跨多个字段的复杂输入规则,可以使用跨字段验证来保持验证逻辑的组织性和可维护性。跨字段验证可确保所有输入值均有效且彼此一致,从而防止出现意外行为。
假设我们有一个表单,用户可以在其中输入任务的开始日期和结束日期,并且我们希望确保结束日期不早于开始日期。我们可以使用跨域验证来实现这一点。
- 首先,我们定义一个自定义验证注解
EndDateAfterStartDate
:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EndDateAfterStartDateValidator.class)
public @interface EndDateAfterStartDate {
String message() default "End date must be after start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- 然后,我们创建验证器
EndDateAfterStartDateValidator
:
public class EndDateAfterStartDateValidator implements ConstraintValidator<EndDateAfterStartDate, TaskForm> {
@Override
public boolean isValid(TaskForm taskForm, ConstraintValidatorContext context) {
if (taskForm.getStartDate() == null || taskForm.getEndDate() == null) {
return true;
}
return taskForm.getEndDate().isAfter(taskForm.getStartDate());
}
}
- 最后,我们将
EndDateAfterStartDate
注释应用于我们的表单对象TaskForm
:
@EndDateAfterStartDate
public class TaskForm {
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
}
现在,当用户提交表单时,验证框架将自动检查结束日期是否晚于开始日期,如果不是,则提供有意义的错误消息。
8 对验证错误使用异常处理
可以使用异常处理ExceptionHandler
来统一捕获和处理验证错误。
以下是如何在 Spring Boot 中使用异常处理来处理验证错误的示例:
@RestControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatus status,
WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", status.value());
// Get all errors
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(x -> x.getDefaultMessage())
.collect(Collectors.toList());
body.put("errors", errors);
return new ResponseEntity<>(body, headers, status);
}
}
在这里,我们创建了一个用 @RestControllerAdvice
注解的 RestExceptionHandler
类来处理我们的 REST API 抛出的异常。然后我们创建一个用 @ExceptionHandler
注解的方法来处理在验证失败时抛出的 MethodArgumentNotValidException
。
在处理程序方法中,我们创建了一个 Map
对象来保存错误响应的详细信息,包括时间戳、HTTP 状态代码和错误消息列表。我们使用 MethodArgumentNotValidException
对象的 getBindingResult()
方法获取所有验证错误并将它们添加到错误消息列表中。
最后,我们返回一个包含错误响应详细信息的ResponseEntity
对象,包括作为响应主体的错误消息列表、HTTP 标头和 HTTP 状态代码。
有了这个异常处理代码,我们的 REST API 抛出的任何验证错误都将被捕获并以结构化和有意义的格式返回给用户,从而更容易理解和解决问题。
9 测试你的验证逻辑
需要为你的验证逻辑编写单元测试,以帮助确保它正常工作。
@DataJpaTest
public class UserValidationTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private Validator validator;
@Test
public void testValidation() {
User user = new User();
user.setFirstName("John");
user.setLastName("Doe");
user.setEmail("invalid email");
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertEquals(1, violations.size());
assertEquals("must be a well-formed email address", violations.iterator().next().getMessage());
}
}
我们使用 JUnit 5 编写一个测试来验证具有无效电子邮件地址的“用户”对象。然后我们使用 Validator
接口来验证 User
对象并检查是否返回了预期的验证错误。
10 考虑客户端验证
客户端验证可以通过向用户提供即时反馈并减少对服务器的请求数量来改善用户体验。但是,不应依赖它作为验证输入的唯一方法。客户端验证很容易被绕过或操纵,因此必须在服务器端验证输入,以确保安全性和数据完整性。
总结
有效的验证对于任何 Web 应用程序的稳定性和安全性都是必不可少的。Spring Boot 提供了一套工具和库来简化验证逻辑并使其更易于维护。通过遵循本文中讨论的最佳实践,您可以确保您的验证组件有效并提供出色的用户体验。
欢迎关注个人公众号【JAVA旭阳】交流学习!