
1. 问题现象:ConstraintValidator的双重错误报告
在开发基于jakarta bean validation的自定义验证逻辑时,我们通常会定义一个注解(如@validsmstextlength)及其对应的constraintvalidator实现(如smstextlengthvalidator)。这个验证器负责对特定业务规则进行校验,并在不满足条件时通过constraintvalidatorcontext.buildconstraintviolationwithtemplate().addconstraintviolation()添加自定义错误消息。
然而,一个常见的问题是,当自定义验证器触发并添加了其特定的错误消息后,BindingResult中却出现了两条错误:
- 默认错误消息: 这条错误通常是基于注解定义中message()属性的默认值(例如DEFAULT_SMS_TEXT_LENGTH_MESSAGE),并且可能指向父级对象或注解所在的字段。
- 自定义错误消息: 这是由isValid()方法内部通过buildConstraintViolationWithTemplate()明确添加的错误,其消息内容和字段路径(通过addPropertyNode()指定)是符合预期的。
这种双重错误报告导致验证结果冗余,可能给前端展示或后端处理带来困扰。
示例场景:
考虑一个SmsMessageDto对象,它包含text和encoding字段,并使用@ValidSmsTextLength进行验证。
// SmsMessageDto.java
@ValidSmsTextLength(groups = { PostGroup.class, PatchGroup.class, PostMessageCampaignGroup.class })
@JsonDeserialize(as = SmsMessageDto.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({"text", "encoding", "messagePartCount", "length"})
public class SmsMessageDto extends AbstractRestDto implements OneOfMessage {
@NotEmpty(message = "SMS_TEXT_NULL_OR_EMPTY", groups = { PostGroup.class, PatchGroup.class })
@JsonProperty("text")
private String text = null;
@ValidParameterByEnum(enumValid = EncodingEnum.class, message = "INVALID_ENCODING_ENUM", groups = {PostGroup.class, PostMessageCampaignGroup.class})
@JsonProperty("encoding")
private EncodingEnum encoding = EncodingEnum.GSM7;
// ... 其他字段和方法
}
// ValidSmsTextLength.java
@Constraint(validatedBy = SmsTextLengthValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSmsTextLength {
String message() default "DEFAULT_SMS_TEXT_LENGTH_MESSAGE"; // 默认错误消息
Class>[] groups() default {};
Class extends Payload>[] payload() default {};
}
// SmsTextLengthValidator.java (原始实现)
public class SmsTextLengthValidator implements ConstraintValidator {
private static final String TEXT = "text";
@Override
public boolean isValid(SmsMessageDto smsMessageDto, ConstraintValidatorContext constraintValidatorContext) {
EncodingEnum encodingEnum = smsMessageDto.getEncoding();
if (smsMessageDto.getText() != null && EncodingEnum.GSM7.equals(encodingEnum) && smsMessageDto.getText().length() > 1530) {
constraintValidatorContext
.buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_GSM7_ERROR") // 自定义错误
.addPropertyNode(TEXT)
.addConstraintViolation();
return false;
} else if (smsMessageDto.getText() != null && EncodingEnum.UNICODE.equals(encodingEnum) && smsMessageDto.getText().length() > 670) {
constraintValidatorContext
.buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_UNICODE_ERROR") // 自定义错误
.addPropertyNode(TEXT)
.addConstraintViolation();
return false;
}
return true;
}
} 当SmsMessageDto的text字段满足GSM7编码且长度超过1530时,BindingResult将包含两个错误:一个默认的DEFAULT_SMS_TEXT_LENGTH_MESSAGE错误,作用于body字段;另一个是自定义的SMS_TEXT_LENGTH_GSM7_ERROR错误,作用于body.text字段。
2. 核心解决方案:禁用默认错误报告
造成这种重复错误的原因在于Bean Validation框架的默认行为。当一个自定义ConstraintValidator被触发时,框架会首先生成一个基于注解message()属性的默认错误。如果isValid()方法内部又通过ConstraintValidatorContext手动添加了新的错误,那么这两个错误都会被收集。
要解决这个问题,我们需要明确告诉验证框架,当前验证器将完全负责错误消息的生成,不再需要自动添加默认错误。这可以通过调用ConstraintValidatorContext接口提供的disableDefaultConstraintViolation()方法来实现。
方法说明:
constraintValidatorContext.disableDefaultConstraintViolation(): 此方法指示验证框架禁用当前验证器关联的默认约束违规。一旦调用,验证器将不再自动生成基于注解message()属性的错误。之后,所有的错误报告都必须通过buildConstraintViolationWithTemplate()方法手动添加。
3. 优化ConstraintValidator实现
将disableDefaultConstraintViolation()方法添加到isValid()方法的开头,确保在任何自定义错误被添加之前,默认错误生成机制就被禁用。
// SmsTextLengthValidator.java (优化后,仅解决默认错误问题) public class SmsTextLengthValidator implements ConstraintValidator{ private static final String TEXT = "text"; @Override public boolean isValid(SmsMessageDto smsMessageDto, ConstraintValidatorContext constraintValidatorContext) { // 禁用默认错误消息,确保只报告自定义错误 constraintValidatorContext.disableDefaultConstraintViolation(); EncodingEnum encodingEnum = smsMessageDto.getEncoding(); if (smsMessageDto.getText() != null && EncodingEnum.GSM7.equals(encodingEnum) && smsMessageDto.getText().length() > 1530) { constraintValidatorContext .buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_GSM7_ERROR") .addPropertyNode(TEXT) .addConstraintViolation(); return false; } else if (smsMessageDto.getText() != null && EncodingEnum.UNICODE.equals(encodingEnum) && smsMessageDto.getText().length() > 670) { constraintValidatorContext .buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_UNICODE_ERROR") .addPropertyNode(TEXT) .addConstraintViolation(); return false; } return true; } }
通过上述修改,当SmsTextLengthValidator执行时,如果触发了自定义错误条件,BindingResult将只包含由buildConstraintViolationWithTemplate()生成的特定错误,而不会再出现默认错误消息。
4. 提升健壮性:处理空对象
除了解决重复错误的问题,自定义ConstraintValidator还需要考虑一个重要的健壮性问题:被验证的对象(在本例中是smsMessageDto)在isValid()方法执行时,可能尚未通过@NotNull等基础注解的验证,因此它本身可能是null。
如果smsMessageDto为null,直接调用smsMessageDto.getEncoding()或smsMessageDto.getText()将会导致NullPointerException。为了避免这种情况,我们应该在isValid()方法的开头添加一个null检查。
最佳实践:添加null检查
// 示例:在isValid方法开头添加null检查 public class SmsTextLengthValidator implements ConstraintValidator{ // ... @Override public boolean isValid(SmsMessageDto smsMessageDto, ConstraintValidatorContext constraintValidatorContext) { // 1. 处理null对象情况 // 如果对象为null,通常由@NotNull等更基础的注解处理。 // 当前验证器不应重复处理null情况,返回true表示它不对null负责。 if (smsMessageDto == null) { return true; } // 2. 禁用默认错误消息 constraintValidatorContext.disableDefaultConstraintViolation(); // 3. 执行具体的验证逻辑 // ... (原有的验证逻辑) return true; } }
通过这种方式,SmsTextLengthValidator将专注于其特定的业务逻辑验证,而基础的非空验证则由@NotNull等注解负责,从而提高了验证器的健壮性和职责分离。
5. 完整且健壮的ConstraintValidator实现
结合禁用默认错误和处理空对象的最佳实践,最终的SmsTextLengthValidator实现如下:
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; // 假设EncodingEnum和SmsMessageDto已在项目中定义 public class SmsTextLengthValidator implements ConstraintValidator{ private static final String TEXT = "text"; @Override public boolean isValid(SmsMessageDto smsMessageDto, ConstraintValidatorContext constraintValidatorContext) { // 1. 处理null对象情况:如果对象为null,通常由@NotNull等注解处理。 // 当前验证器不应重复处理null情况,返回true表示它不对null负责。 if (smsMessageDto == null) { return true; } // 2. 禁用默认错误消息:确保只报告自定义错误,避免默认消息的干扰。 constraintValidatorContext.disableDefaultConstraintViolation(); EncodingEnum encodingEnum = smsMessageDto.getEncoding(); String text = smsMessageDto.getText(); // 3. 执行具体的验证逻辑 // 同时考虑text字段本身可能为null的情况,避免NullPointerException if (text != null) { if (EncodingEnum.GSM7.equals(encodingEnum) && text.length() > 1530) { constraintValidatorContext .buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_GSM7_ERROR") .addPropertyNode(TEXT) .addConstraintViolation(); return false; } else if (EncodingEnum.UNICODE.equals(encodingEnum) && text.length() > 670) { constraintValidatorContext .buildConstraintViolationWithTemplate("SMS_TEXT_LENGTH_UNICODE_ERROR") .addPropertyNode(TEXT) .addConstraintViolation(); return false; } } // 如果所有条件都通过,则验证成功 return true; } }
6. 总结与注意事项
- disableDefaultConstraintViolation()是关键: 当自定义ConstraintValidator需要完全控制错误消息的生成时,务必调用constraintValidatorContext.disableDefaultConstraintViolation()。这能有效避免BindingResult中出现默认错误消息与自定义错误消息并存的冗余情况。
- null检查的重要性: 在isValid()方法中,被验证的对象可能为null。为了避免NullPointerException,始终在方法开头进行null检查,并让@NotNull等注解处理对象本身的非空验证。
- 职责分离: 自定义验证器应专注于其特定的业务逻辑验证,而基础的非空验证、类型转换等应交由更基础的注解或框架机制处理。
- 错误消息定义: 确保自定义错误消息模板(如SMS_TEXT_LENGTH_GSM7_ERROR、SMS_TEXT_LENGTH_UNICODE_ERROR)在ValidationMessages.properties或其他配置中正确定义,以便框架能够解析并提供用户友好的错误信息。
遵循这些最佳实践,可以构建出更加健壮、清晰且专业的Jakarta Bean Validation自定义验证逻辑。










