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

Posted

技术标签:

【中文标题】如何对 jsf 复合组件中的集合属性进行 bean 验证,约束不会触发【英文标题】:How to bean-validate a collection property in a jsf composite component, constraints do not fire 【发布时间】:2021-04-24 18:22:28 【问题描述】:

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

我们有一个引用细节集合的实体。两者都使用 bean-validation-constraints 进行注释。请注意details-property 处的注释。

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 中的保存按钮时,name-inputText 被正确验证,但 details-property 上的 @NotEmpty- 和 @UniqueCategory-constraint 被忽略。

我错过了什么?

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

【问题讨论】:

【参考方案1】:

在深入研究后,我们最终找到了一个使用支持组件ValidateListComponent 的解决方案。它的灵感来自 UIValidateWholeBeanWholeBeanValidator。 该组件从UIInput 扩展并覆盖验证方法以对上述details-collection 的克隆进行操作,其中填充了children-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 给出的答案解决了这个问题。

【讨论】:

以上是关于如何对 jsf 复合组件中的集合属性进行 bean 验证,约束不会触发的主要内容,如果未能解决你的问题,请参考以下文章

部署 JSF 复合组件以供共享使用

JSF 2.2 ViewScoped Bean 被多次创建

如何将 JSF 组件绑定到支持 bean 属性

JSF拒绝处理深层嵌套的复合组件。不,真的:“JSF1098:[...]这浪费了处理器时间[...]”

如何将验证器属性从 JSF2 复合组件传递给 h:inputText?

JSF2.0 - 具有可选方法表达式的复合组件