如何在 jsf 复合组件中对集合 属性 进行 bean 验证,约束不会触发

How to bean-validate a collection property in a jsf composite component, constraints do not fire

如何正确定义 jsf 复合组件,以便在其包含集合的情况下正确地对其值进行 bean 验证?

我们有一个引用详细信息集合的实体。两者都带有 bean-validation-constraints 注释。请注意 details-属性.

处的注释
public class Entity implements Serializable {

    @NotEmpty
    private String name;

    @NotEmpty
    @UniqueCategory(message="category must be unique")    
    private List<@Valid Detail> details;

    /* getters/setters */
}

public class Detail implements Serializable {

    @Pattern(regexp="^[A-Z]+$")
    private String text;

    @NotNull
    private Category category;

    /* getters/setters */
}

public class Category implements Serializable {

    private final int id;
    private final String description;

    Category(int id, String description) {
        this.id = id;
        this.description = description;
    }

    /* getters/setters */
}

public class MyConstraints {

    @Target({ ElementType.TYPE, ElementType.FIELD })
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = UniqueCategoryValidator.class)
    @Documented
    public static @interface UniqueCategory {
        String message();

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

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

    public static class UniqueCategoryValidator implements ConstraintValidator<UniqueCategory, Collection<Detail>> {

        @Override
        public boolean isValid(Collection<Detail> collection, ConstraintValidatorContext context) {
            if ( collection==null || collection.isEmpty() ) {
                return true;
            }
            Set<Category> set = new HashSet<>();
            collection.forEach( d-> set.add( d.getCategory() ));
            return set.size() == collection.size();
        }

        public void initialize(UniqueCategory constraintAnnotation) {
            // intentionally empty
        }
    }

    private MyConstraints() {
        // only static stuff
    }
}

实体可以用jsf形式编辑,其中所有涉及细节的任务都封装在一个复合组件中,例如

 <h:form id="entityForm">
    <h:panelGrid columns="3">
        <p:outputLabel for="@next" value="name"/>
        <p:inputText id="name" value="#{entityUiController.entity.name}"/>
        <p:message for="name"/>

        <p:outputLabel for="@next" value="details"/>
        <my:detailsComponent id="details" details="#{entityUiController.entity.details}"
            addAction="#{entityUiController.addAction}"/>
        <p:message for="details"/>

        <f:facet name="footer">
            <p:commandButton id="saveBtn" value="save"
                action="#{entityUiController.saveAction}"
                update="@form"/>
        </f:facet>
    </h:panelGrid>
</h:form>

其中 my:detailsComponent 定义为

<ui:component xmlns="http://www.w3.org/1999/xhtml"
    xmlns:h="http://java.sun.com/jsf/html"
    xmlns:f="http://java.sun.com/jsf/core"
    xmlns:ui="http://java.sun.com/jsf/facelets"
    xmlns:cc="http://java.sun.com/jsf/composite"
    xmlns:p="http://primefaces.org/ui"
    >

    <cc:interface>
        <cc:attribute name="details" required="true" type="java.lang.Iterable"/>
        <cc:attribute name="addAction" required="true" method-signature="void action()"/>
    </cc:interface>

    <cc:implementation>
        <p:outputPanel id="detailsPanel">
            <ui:repeat id="detailsContainer" var="detail" value="#{cc.attrs.details}">
                <p:inputText id="text" value="#{detail.text}" />
                <p:message for="text"/>
                <p:selectOneMenu id="category" value="#{detail.category}"
                    converter="#{entityUiController.categoriesConverter}"
                    placeholder="please select" >
                    <f:selectItem noSelectionOption="true" />
                    <f:selectItems value="#{entityUiController.categoryItems}"/>
                </p:selectOneMenu>
                <p:message for="category"/>
            </ui:repeat>
        </p:outputPanel>
        <p:commandButton id="addDetailBtn" value="add" action="#{cc.attrs.addAction}"
            update="detailsPanel" partialSubmit="true" process="@this detailsPanel"/>
    </cc:implementation>
</ui:component>

并且 EntityUiController 是

@Named
@ViewScoped
public class EntityUiController implements Serializable {

    private static final Logger LOG = Logger.getLogger( EntityUiController.class.getName() );

    @Inject
    private CategoriesBoundary categoriesBoundary;

    @Valid
    private Entity entity;

    @PostConstruct
    public void init() {
        this.entity = new Entity();
    }

    public Entity getEntity() {
        return entity;
    }

    public void saveAction() {
        LOG.log(Level.INFO, "saved entity: {0}", this.entity );
    }

    public void addAction() {
        this.entity.getDetails().add( new Detail() );
    }
    
    public List<SelectItem> getCategoryItems() {
        return categoriesBoundary.getCategories().stream()
            .map( cat -> new SelectItem( cat, cat.getDescription() ) )
            .collect( Collectors.toList() );
    }

    public Converter<Category> getCategoriesConverter() {
        return new Converter<Category>() {

            @Override
            public String getAsString(FacesContext context, UIComponent component, Category value) {
                return value==null ? null : Integer.toString( value.getId() );
            }

            @Override
            public Category getAsObject(FacesContext context, UIComponent component, String value) {
                if ( value==null || value.isEmpty() ) {
                    return null;
                }
                try {
                    return categoriesBoundary.findById( Integer.valueOf(value).intValue() );
                } catch (NumberFormatException e) {
                    throw new ConverterException(e);
                }
            }
        };
    }
}

当我们现在按下上面 h:form 中的保存按钮时,名称输入文本已正确验证,但 @NotEmpty- 和 @UniqueCategory- 对详细信息的约束-属性 被忽略。

我错过了什么?

我们在 java-ee-7,payara 4。

深入研究之后,我们最终找到了一个使用支持组件的解决方案 ValidateListComponent。它的灵感来自 UIValidateWholeBeanWholeBeanValidator。 该组件从 UIInput 扩展并覆盖验证方法以对上述 details 集合的克隆进行操作,该集合填充了子 UIInput 的已验证值。似乎暂时有效。

<ui:component ...>
    <cc:interface componentType="validatedListComponent">
        <cc:attribute name="addAction" required="true" method-signature="void action()"/>
    </cc:interface>

    <cc:implementation>
        ... see above ...
    </cc:implementation>
</ui:component>

支持组件定义为

@FacesComponent(value = "validatedListComponent")
@SuppressWarnings("unchecked")
public class ValidatedListComponent extends UIInput implements NamingContainer {

    @Override
    public String getFamily() {
        return "javax.faces.NamingContainer";
    }

    /**
     * Override {@link UIInput#processValidators(FacesContext)} to switch the order of 
     * validation. First validate this components children, then validate this itself.
     */
    @Override
    public void processValidators(FacesContext context) {
    
        // Skip processing if our rendered flag is false
        if (!isRendered()) {
            return;
        }

        pushComponentToEL(context, this);

        for (Iterator<UIComponent> i = getFacetsAndChildren(); i.hasNext(); ) {
            i.next().processValidators(context);
        }
        if (!isImmediate()) {
            Application application = context.getApplication();
            application.publishEvent(context, PreValidateEvent.class, this);
            executeValidate(context);
            application.publishEvent(context, PostValidateEvent.class, this);
        }

        popComponentFromEL(context);
    }

    /**
     * Override {@link UIInput#validate(FacesContext)} to validate a cloned collection
     * instead of the submitted value.
     *
     * Inspired by {@link UIValidateWholeBean} and {@link WholeBeanValidator}.
     */
    @Override
    public void validate(FacesContext context) {

        AreDetailsValidCallback callback = new AreDetailsValidCallback();
        visitTree( VisitContext.createVisitContext(context)
                 , callback
                 );
        if ( callback.isDetailsValid() ) {
            Collection<?> clonedValue = cloneCollectionAndSetDetailValues( context );
            validateValue(context, clonedValue);
        }
    }

    /**
     * private method copied from {@link UIInput#executeValidate(FacesContext)}.
     * @param context
     */
    private void executeValidate(FacesContext context) {
        try {
            validate(context);
        } catch (RuntimeException e) {
            context.renderResponse();
            throw e;
        }

        if (!isValid()) {
            context.validationFailed();
            context.renderResponse();
        }
    }

    private Collection<Object> cloneCollectionAndSetDetailValues(FacesContext context) {
        ValueExpression collectionVE = getValueExpression("value");
        Collection<?> baseCollection = (Collection<?>) collectionVE.getValue(context.getELContext());
        if ( baseCollection==null ) {
           return null;
        }

        // Visit all the components children to find their already validated values.
        FindDetailValuesCallback callback = new FindDetailValuesCallback(context);
        this.visitTree( VisitContext.createVisitContext(context)
                , callback
                );

        // Clone this components value and put in all cloned details with validated values set.
        try {
            Collection<Object> clonedCollection = baseCollection.getClass().newInstance();

            for( Entry<Object,Map<String,Object>> entry : callback.getDetailSubmittedData().entrySet() ) {
                Object clonedDetail = cloneDetailAndSetValues( entry.getKey(), entry.getValue() );
                clonedCollection.add( clonedDetail );
            }

            return clonedCollection;
        } catch ( Exception e ) {
           throw new ConverterException(e);
        }
    }

    private <T> T cloneDetailAndSetValues(T detail, Map<String, Object> propertyMap) throws Exception {
        T clonedDetail = clone(detail);
        // check the properties we have in the detail
        Map<String, PropertyDescriptor> availableProperties = new HashMap<>();
        for (PropertyDescriptor propertyDescriptor : getBeanInfo(detail.getClass()).getPropertyDescriptors()) {
            availableProperties.put(propertyDescriptor.getName(), propertyDescriptor);
        }
        // put their value (or local value) into our clone
        for (Map.Entry<String, Object> propertyToSet : propertyMap.entrySet()) {
            availableProperties.get(propertyToSet.getKey()).getWriteMethod().invoke(clonedDetail,
                    propertyToSet.getValue());
        }

        return clonedDetail;
    }

    private static <T> T clone(T object) throws Exception {
        // clone an object using serialization.
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream);

        out.writeObject(object);
        byte[] bytes = byteArrayOutputStream.toByteArray();

        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        ObjectInputStream in = new ObjectInputStream(byteArrayInputStream);

        return (T) in.readObject();
    }

    private class FindDetailValuesCallback implements VisitCallback {

        private final FacesContext context;
        private final Map<Object, Map<String, Object>> detailSubmittedData = new HashMap<>();

        public FindDetailValuesCallback(final FacesContext context) {
            this.context = context;
        }

        final Map<Object, Map<String, Object>> getDetailSubmittedData() {
            return detailSubmittedData;
        }

        @Override
        public VisitResult visit(VisitContext visitContext, UIComponent component) {
            if ( isVisitorTarget(component) ) {
                ValueExpression ve = component.getValueExpression("value");
                Object value = ((EditableValueHolder)component).getValue();

                if (ve != null) {

                    ValueReference vr = ve.getValueReference(context.getELContext());
                    String prop = (String)vr.getProperty();
                    Object base = vr.getBase();

                    Map<String, Object> propertyMap
                        = Optional.ofNullable( detailSubmittedData.get(base) )
                           .orElseGet( HashMap::new );
                    propertyMap.put(prop, value );

                    detailSubmittedData.putIfAbsent( base, propertyMap);
                }
            }

            return ACCEPT;
        }

    }

    private class AreDetailsValidCallback implements VisitCallback {

        private boolean detailsValid;

        public AreDetailsValidCallback() {
            this.detailsValid = true;
        }

        public boolean isDetailsValid() {
            return detailsValid;
        }

        @Override
        public VisitResult visit(VisitContext visitContext, UIComponent component) {
            if ( isVisitorTarget(component) ) {
                if ( !((EditableValueHolder)component).isValid() ) {
                    this.detailsValid = false;
                }
            }
            return ACCEPT;
        }

    }

    private boolean isVisitorTarget( UIComponent component ) {
        return component instanceof EditableValueHolder && component.isRendered()
                && component!=ValidatedListComponent.this;
    }
}

更新:有时在 FindDetailValuesCallback#visit 中获取 ValueReference 是个问题。 Michele here 给出的答案解决了这个问题。