带有额外信息的 Bean 验证

Bean Validation with Extra Information

我正在尝试创建一个 UniqueName 注释作为创建项目的自定义 bean 验证注释 api:

@PostMapping("/users/{userId}/projects")
public ResponseEntity createNewProject(@PathVariable("userId") String userId,
                                       @RequestBody @Valid ProjectParam projectParam) {
    User projectOwner = userRepository.ofId(userId).orElseThrow(ResourceNotFoundException::new);

    Project project = new Project(
        IdGenerator.nextId(),
        userId,
        projectParam.getName(),
        projectParam.getDescription()
    );
    ...
  }

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class ProjectParam {

  @NotBlank
  @NameConstraint
  private String name;
  private String description;
}

@Constraint(validatedBy = UniqueProjectNameValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface UniqueName {

    public String message() default "already existed";

    public Class<?>[] groups() default {};

    public Class<? extends Payload>[] payload() default{};
}

public class UniqueProjectNameValidator implements ConstraintValidator<UniqueName, String> {
   @Autowired
   private ProjectQueryMapper mapper;

   public void initialize(UniqueName constraint) {
   }

   public boolean isValid(String value, ConstraintValidatorContext context) {
      // how can I get the userId info??
      return mapper.findByName(userId, value) == null;
   }
}

问题是 name 字段只需要用户级别的唯一性。所以我需要从 URL 字段中获取 {userId} 进行验证。但是如何将其添加到 UniqueProjectNameValidator 中?还是有更好的方法来处理此验证?这只是一个大对象的一小部分,真正的对象在请求处理程序中有许多其他复杂的验证,这使得代码很脏。

如果我没记错的话,你要问的是,如何将你的 userId 传递给你的自定义注释,即 @UniqueName 以便你可以访问 userId 到针对传递的 userId.

根据现有 projectNames 验证 projectName 字段

这意味着你问的是,如何将 variable/parameter 动态传递给注解,这是不可能的。您必须使用其他一些方法,例如 Interceptors 手动执行验证

您也可以参考以下答案:

Passing dynamic parameters to an annotation?

正如@Abhijeet 提到的,动态地将 userId 属性 传递给约束验证器是不可能的。至于如何更好地处理这个验证案例,有干净的解决方案和肮脏的解决方案。

干净的解决方案 是将所有业务逻辑提取到服务方法中,并在服务级别验证ProjectParam。这样,您可以将 userId 属性 添加到 ProjectParam,并在调用服务之前将其从 @PathVariable 映射到 @RequestBody。然后调整 UniqueProjectNameValidator 以验证 ProjectParam 而不是 String

肮脏的解决方案 是使用 Hibernate Validator 的 cross-parameter constraints (see also this link 作为示例)。您基本上将 两个控制器方法参数 视为自定义验证器的输入。

@Mikhail Dyakonov 在这个 article 中提出了一个经验法则来选择使用 java 的最佳验证方法:

  • JPA validation has limited functionality, but it is a great choice for the simplest constraints on entity classes if such constraints can be mapped to DDL.

  • Bean Validation is a flexible, concise, declarative, reusable, and readable way to cover most of the checks that you could have in your domain model classes. This is the best choice, in most cases, once you don't need to run validations inside a transaction.

  • Validation by Contract is a Bean validation for method calls. You can use it when you need to check input and output parameters of a method, for example, in a REST call handler.

  • Entity listeners although they are not as declarative as the Bean validation annotations, they are a great place to check big object's graphs or make a check that needs to be done inside a database transaction. For example, when you need to read some data from the DB to make a decision, Hibernate has analogs of such listeners.

  • Transaction listeners are a dangerous yet ultimate weapon that works inside the transactional context. Use it when you need to decide at runtime what objects have to be validated or when you need to check different types of your entities against the same validation algorithm.

我认为 Entity listeners 符合您的 unique constraint 验证问题,因为在 Entity Listener 中您将能够访问您的 JPA persisting/updating 它之前的实体,更容易执行您的检查查询。

然而,正如@crizzis 指出的那样,这种方法有很大的限制。如 JPA 2 规范 (JSR 317) 所述:

In general, the lifecycle method of a portable application should not invoke EntityManager or Query operations, access other entity instances, or modify relationships within the same persistence context. A lifecycle callback method may modify the non-relationship state of the entity on which it is invoked.

无论您是否尝试这种方法,首先您需要一个 ApplicationContextAware 实现来获取当前 EntityManager 实例。这是一个古老的 Spring 框架 技巧,也许你已经在使用它了。

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public final class BeanUtil implements ApplicationContextAware {

   private static ApplicationContext CONTEXT;

        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            CONTEXT = applicationContext;
        }

        public static <T> T getBean(Class<T> beanClass) {
            return CONTEXT.getBean(beanClass);
        }    
    }

这是我的实体监听器

@Slf4j
public class GatewaUniqueIpv4sListener { 

    @PrePersist
    void onPrePersist(Gateway gateway) {       
       try {
           EntityManager entityManager = BeanUtil.getBean(EntityManager.class);
           Gateway entity = entityManager
                .createQuery("SELECT g FROM Gateway g WHERE g.ipv4 = :ipv4", Gateway.class)
                .setParameter("ipv4", gateway.getIpv4())
                .getSingleResult();

           // Already exists a Gateway with the same Ipv4 in the Database or the PersistenceContext
           throw new IllegalArgumentException("Can't be to gateways with the same Ip address " + gateway.getIpv4());
       } catch (NoResultException ex) {
           log.debug(ex.getMessage(), ex);
       }
    }
}

最后,我将此注释添加到我的 实体 Class @EntityListeners(GatewaUniqueIpv4sListener.class)

您可以在此处找到完整的工作代码gateways-java

一种简洁明了的方法是检查您需要在 transactional services 中访问数据库的验证。即使您可以使用 SpecificationStrategyChain of Responsibility 模式来实现更好的解决方案。

我相信您可以完成您所要求的,但您可能需要稍微概括一下您的方法。

正如其他人所提到的,您不能将两个属性传递给验证器,但是,如果您将验证器更改为 class 级验证器而不是字段级验证器,它就可以工作。

这是我们创建的验证器,可确保提交时两个字段的值相同。想想您经常在网站上看到的密码和确认密码用例,或者电子邮件和确认电子邮件用例。

当然,在您的特定情况下,您需要传入用户 ID 和他们尝试创建的项目的名称。

注解:

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * Taken from:
 * 
 * <p/>
 * Validation annotation to validate that 2 fields have the same value.
 * An array of fields and their matching confirmation fields can be supplied.
 * <p/>
 * Example, compare 1 pair of fields:
 *
 * @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match")
 * <p/>
 * Example, compare more than 1 pair of fields:
 * @FieldMatch.List({
 * @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match"),
 * @FieldMatch(first = "email", second = "confirmEmail", message = "The email fields must match")})
 */
@Target({TYPE, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = FieldMatchValidator.class)
@Documented
public @interface FieldMatch {
    String message() default "{constraints.fieldmatch}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    /**
     * @return The first field
     */
    String first();

    /**
     * @return The second field
     */
    String second();

    /**
     * Defines several <code>@FieldMatch</code> annotations on the same element
     *
     * @see FieldMatch
     */
    @Target({TYPE, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        FieldMatch[] value();
    }
}

验证者:

import org.apache.commons.beanutils.BeanUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * Taken from:
 * 
 */
public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {
    private String firstFieldName;
    private String secondFieldName;

    @Override
    public void initialize(FieldMatch constraintAnnotation) {

        firstFieldName = constraintAnnotation.first();
        secondFieldName = constraintAnnotation.second();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {

        try {
            Object firstObj = BeanUtils.getProperty(value, firstFieldName);
            Object secondObj = BeanUtils.getProperty(value, secondFieldName);

            return firstObj == null && secondObj == null || firstObj != null && firstObj.equals(secondObj);
        } catch (Exception ignore) {
            // ignore
        }
        return true;
    }
}

然后是我们的命令对象:

import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;

import javax.validation.GroupSequence;

@GroupSequence({Required.class, Type.class, Data.class, Persistence.class, ChangePasswordCommand.class})
@FieldMatch(groups = Data.class, first = "password", second = "confirmNewPassword", message = "The New Password and Confirm New Password fields must match.")
public class ChangePasswordCommand {

    @NotBlank(groups = Required.class, message = "New Password is required.")
    @Length(groups = Data.class, min = 6, message = "New Password must be at least 6 characters in length.")
    private String password;

    @NotBlank(groups = Required.class, message = "Confirm New Password is required.")
    private String confirmNewPassword;

    ...
}