Programming Thoughts
Struts 2 - Annotation-based Validation
Bespoke Annotations
Actually reducing Struts 2's arcane configuration
Struts 2 is a popular MVC framework for Java-based web applications but its annotation-based validation doesn't properly work. So, an alternative was created. It supports client written, custom policies but the form field annotation isn't as readable as the built-in annotations.
Custom Validators Need Work
Client-written, custom adjusters, converters, and validators can be easily written but the form field annotation may not be obvious at first glance. Consider the following example, which configures an age range and the message keys to display for rejection.
@CustomPostConversion(param1 = "15", param2 = "validation.dateofbirth.minage", param3 = "150", param4 = "validation.dateofbirth.mmaxage", validatorClass = AgeRangeValidator.class) private Date dateOfBirth;
This would be more readable if it was like the following and, as a bonus, use of integer parameters avoids typo parsing.
@AgeRange(minAge = 15, minAgeMessageKey = "validation.dateofbirth.minage", maxAge = 150, maxAgeMessageKey = "validation.dateofbirth.mmaxage") private Date dateOfBirth;
Classpath Scanning
The problem is such bespoke annotations are client written, so the annotation validation interceptor can't recognise them by name. A table of annotations and their linked policies must be created at run-time but requiring the client to add each annotation is the kind of arcane configuration to be avoided.
Thus, the classpath must be scanned at run-time and this can be done with the award winning ClassGraph. The library achieves this by reading class files without invoking any classloader but tests show even this requires around three seconds to run for an entire classpath of a Tomcat app. Fortunately, it's far faster if restricted by package name. Imposing a three second delay by default is bad engineering but a design objective is to use defaults and remove boilerplate code as much as possible. The compromise is if a developer wants to write a bespoke policy, rather than a custom one, he must switch on scanning and set the package name in the struts.xml file, like below. This only needs to be done once per app and is better than code to add each policy.
<constant name="name.matthewgreet.strutscommons.accept_packages" value="name.matthewgreet.example11.policy" /> <constant name="enable_classpath_scanning" value="true" />
Though restricting package makes ClassGraph much faster, extracting class info still needs to include superclasses. Thus, the built-in policy package must be added, as shown below. See ClassGraph Constructor API for configuring a ClassGraph scan.
public static final String BASE_POLICY_PACKAGES = "name.matthewgreet.strutscommons.policy"; ... workingAcceptPackages = new ArrayList<>(); if (acceptPackages.size() > 0 || acceptClasses.size() > 0) { // Classgraph needs to find policy interfaces and support classes to find classes implementing them workingAcceptPackages.add(BASE_POLICY_PACKAGES); workingAcceptPackages.addAll(acceptPackages); } classGraph = new ClassGraph(); classGraph.enableClassInfo(); classGraph.acceptClasses(acceptClasses.toArray(new String[0])); classGraph.acceptPackages(workingAcceptPackages.toArray(new String[0])); classGraph.rejectClasses(rejectClasses.toArray(new String[0])); classGraph.rejectPackages(rejectPackages.toArray(new String[0])); try (ScanResult scanResult = classGraph.scan()) { ... }
However, more information is needed than just the class name. The annotation class name, and data type for
policies of conversion and later steps, must be extracted. Consider an example post-conversion validator
below that implements PostConversionValidator<A extends Annotation,T>.
It does not directly implement the interface, so the actual type parameters can't be directly read. It does declare actual parameters of
AbstractPostConversionValidatorSupport<A extends Annotation,T>,
which are in the same order, but client written policies aren't required to extend the built-in template
classes.
Thus, the class hierarchy must be traced to the required interface. In the case of the example:-
AbstractPostConversionValidatorSupport<A extends Annotation,T>
extends AbstractPolicySupport<A> implements PostConversionValidator<A,T> |
AgeRangeValidator extends AbstractPostConversionValidatorSupport<AgeRange,Date> |
Thus, the first step is, for each class in the hierarchy, to note the actual type parameters and generic type parameters of the superclass till the required interface is found. When it is found, note the type parameters of the interface. If the needed generic type parameter does not match any of the actual type parameters, it's a real class name and the answer found. The code for this is below.
protected String actualTypeFromClassInfoForDirectInterface(ClassInfo candidateClassInfo, Class<?> interfaceClass, int genericTypeIndex) { ... interfaceFound = false; result = null; genericTypeDeclarations = new ArrayList<>(); workingTypeIndex = -1; // Traces superclasses till expected interface found workingClassInfo = candidateClassInfo; while (workingClassInfo != null && !interfaceFound) { workingSignature = workingClassInfo.getTypeSignature().getSuperclassSignature(); genericTypeDeclaration = new GenericTypeDeclarationClassGraph(); genericTypeDeclaration.setGenericsDeclaration(workingClassInfo.getTypeSignature().getTypeParameters()); genericTypeDeclaration.setSuperclassGenericsDeclaration(workingSignature.getTypeArguments()); genericTypeDeclarations.add(genericTypeDeclaration); workingTypeSignature = workingClassInfo.getTypeSignature(); directInterfaceSignatures = workingTypeSignature.getSuperinterfaceSignatures(); for (ClassRefTypeSignature directInterfaceSignature: directInterfaceSignatures) { if (directInterfaceSignature.getBaseClassName().equals(interfaceClass.getName())) { typeArguments = directInterfaceSignature.getTypeArguments(); workingGenericName = typeArguments.get(genericTypeIndex).getTypeSignature().toString(); interfaceFound = true; for (int i = 0; i < genericTypeDeclaration.getGenericsDeclaration().size(); i++) { typeParameter = genericTypeDeclaration.getGenericsDeclaration().get(i); if (workingGenericName.equals(typeParameter.getName())) { workingTypeIndex = genericTypeIndex; break; } } if (workingTypeIndex < 0) { // Doesn't match any generic parameter, so name is actual classname return workingGenericName; } else if (interfaceFound) { // Sets interface actual arguments of first to be read entry genericTypeDeclaration.setSuperclassGenericsDeclaration(typeArguments); break; } } } workingClassInfo = workingClassInfo.getSuperclass(); } }
For the example, the extracted data becomes:-
| Class | Actual type parameters | Superclass/interface | Generic type parameters |
|---|---|---|---|
| AgeRangeValidator | [] | AbstractPostConversionValidatorSupport | [AgeRange,Date] |
| AbstractPostConversionValidatorSupport | [A extends Annotation,T] | PostConversionValidator | [A,T] |
The next step is to start with the interface's required type parameter, find the position of the actual type parameter with the same name, trace to the same position in the subclass generic type parameters, and repeat till a generic type parameter does not match an actual type parameter, meaning it's a class name. The code is shown below.
if (interfaceFound && genericTypeDeclarations.size() > 0) { // Starts with required generic type of declaring class and traces type declarations of subclasses for (declarationIndex = genericTypeDeclarations.size() - 1; declarationIndex >= 0; declarationIndex--) { typeArgument = genericTypeDeclarations.get(declarationIndex).getSuperclassGenericsDeclaration().get(workingTypeIndex); if (typeArgument.getTypeSignature() == null) { break; } workingGenericName = typeArgument.getTypeSignature().toString(); j = 0; genericTypeFound = false; for (TypeParameter genericTypeDeclaration2: genericTypeDeclarations.get(declarationIndex).getGenericsDeclaration()) { if (genericTypeDeclaration2.getName().equals(workingGenericName)) { workingTypeIndex = j; genericTypeFound = true; break; } j++; } if (!genericTypeFound) { break; } } if (declarationIndex >= 0) { // If -1, class is generic and does not define annotation or recipient type result = genericTypeDeclarations.get(declarationIndex).getSuperclassGenericsDeclaration().get(workingTypeIndex).toString(); } } return result;
For the example and annotation, this is illustrated below.
| Class | Actual type parameters | Superclass/interface | Generic type parameters |
|---|---|---|---|
| AgeRangeValidator | [] | AbstractPostConversionValidatorSupport | [AgeRange,Date] |
| AbstractPostConversionValidatorSupport | [A extends Annotation,T] | PostConversionValidator | [A,T] |
Default Converters
Classpath scanning also finds default converters and collection converters with the data type they convert to, adding to a lookup table by data type. If two are found sharing the same data type, the client probably has an app-specific converter clashing with a shared library one. This suggests choosing the one with the package name most like the Struts Action in operation but imposing a package naming structure on clients for rare cases won't be popular and still leaves indeterminate cases. Thus, for such rare cases, resolution of clashing default converters is arbitrary and left to manual setting.
There is also client written default converters clashing with the built-in converters. Being written by the client suggests the developer chooses it but this is vulnerable to new converters in shared libraries breaking apps. Better to leave it the client by switching on config or manual setting.
Polymorphic Policy Lookup
As the policy and default converter lookup tables are no longer static and hardcoded, they can be made into a substitutable object with a defined set of functions. In other words, polymorphism based on an interface and a different set of policies and be switched by client code. There is no use case for this but the separation already existed and a library should prefer flexible design. The interface is shown below and javadoc at PolicyLookup.
public interface PolicyLookup { public Collection<DefaultCollectionConverterEntry<?,?>> getDefaultCollectionConverterEntries(); public <C extends CollectionConverter<?,T>,T> DefaultCollectionConverterEntry<T,C> getDefaultCollectionConverterEntry(Class<? extends T> itemClass); public Collection<DefaultConverterEntry<?,?>> getDefaultConverterEntries(); public <C extends Converter<?,T>,T> DefaultConverterEntry<T,C> getDefaultConverterEntry(Class<? extends T> fieldClass); public Collection<PolicyEntry<?,?,?>> getPolicyEntries(); public <A extends Annotation,P extends Policy<A>,T> PolicyEntry<A,P,T> getPolicyEntry(Class<? extends A> annotationClass); public <C extends CollectionConverter<?,T>,T> DefaultCollectionConverterEntry<T,?> putDefaultCollectionConverter(Class<C> collectionConverterClass) throws PolicyLookupRejectionException; public <C extends Converter<?,T>,T> DefaultConverterEntry<T,?> putDefaultConverter(Class<C> converterClass) throws PolicyLookupRejectionException; public <A extends Annotation,P extends Policy<A>,T> PolicyEntry<A,?,?> putPolicy(Class<P> policyClass) throws PolicyLookupRejectionException; public <C extends CollectionConverter<?,T>,T> DefaultCollectionConverterEntry<T,C> removeDefaultCollectionConverter(Class<? extends T> itemClass) throws PolicyLookupRejectionException; public <C extends Converter<?,T>,T> DefaultConverterEntry<T,C> removeDefaultConverter(Class<? extends T> fieldClass) throws PolicyLookupRejectionException; public <A extends Annotation,P extends Policy<A>,T> PolicyEntry<A,P,T> removePolicy(Class<? extends A> annotationClass) throws PolicyLookupRejectionException; }
There is a default implementation of this, accessed using DefaultPolicyLookup.getInstance().
Different policy lookups can be utilised by using a subclass of AnnotationValidationInterceptor2
and FormFormatterInterceptor in interceptor stacks and overriding their makePolicyLookup
methods. Also, call StrutsMiscellaneousLibrary.updateDisplayWithPolicyLookup with the
appropriate lookup instead of StrutsMiscellaneousLibrary.updateDisplay.
Manual Setting
Manual setting of policy and default converter lookup, if needed, only needs to be set once but as Struts is request based, it doesn't provide a convenient hook for server startup. Most Struts apps have an Action for the initial login but it's best not to assume this is always called first. The standard way initialise on server startup is adding a servlet context listener to the web.xml, such as below.
<web-app> ... <listener> <listener-class>name.matthewgreet.experiment3.util.StartupListener</listener-class> </listener> ... </web-app> public class StartupListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { DefaultPolicyLookup defaultPolicyLookup; defaultPolicyLookup = DefaultPolicyLookup.getInstance(); try { defaultPolicyLookup.putPolicy(CurrencyConverter.class); defaultPolicyLookup.putPolicy(ThousandIntegerConverter.class); defaultPolicyLookup.putDefaultConverter(ThousandIntegerConverter.class); } catch (PolicyLookupRejectionException e) { e.printStackTrace(); } } }
Next part
Continued in Bespoke Annotations Example.