In todays blog post, I'm showing how I implemented highly configurable, and modular classes with the help of annotations, and enumerations. From now on, let's call these modular classes "modules". Let's suppose we have the following architectural problem:
- There is a need for modules that are highly configurable.
- The modules implement the same interface.
- The configuration parameters vary.
- Some configuration parameters are required.
- Some configuration parameters have a default value.
- A configuration parameter should have an explanation for the enduser.
- It should also be easy to add new modules.
UML Class Diagram
ModuleFactory and ModuleFactoryImpl
The module factory is responsible for creating a configured module. It takes the class name, and Properties object as parameters. I'm using a class name here instead of a class type, because the list of supported modules are stored in the database as names. The Properties object contains the configuration for the module to be created.
ModuleType
This enumeration is basically a list of supported types by the factory. When a new module is implemented, it should be added to this enumeration, otherwise the factory doesn't know how to translate the class name to the actual class. I tried to find a way to avoid the need of this enumeration, like searching in the class path for classes that correspond to the name. Unfortunately, this is too complex to achieve without adding a lot of boilerplate code. If you find a better way to solve this problem, please let me know in the comments.
Module
This interface formalizes the methods implemented by all modules. Their parameters are defined in an enumeration of type ModuleParameter. Every module should have a corresponding ModuleParameter enumeration. An example implementation for this demo is called "ExampleModule", and it uses "ExampleModuleParameter" to define its configuration.
ModuleParameter
This interface specifies the required methods for every subclass enumeration implementation. The enumeration is basically a list of configuration parameters for a module. Per configuration parameter, it also defines: if the parameter is required, the default value, and a brief explanation. An example implementaton for this demo is called "ExampleModuleParameter", and it has two configuration parameters.
The Implementation
ModuleParameter
package com.javaeenotes.modules; // This interface enforces required methods for the parameter enumerator. public interface ModuleParameter { boolean isRequired(); String getDefaultValue(); String getExplanation(); }
This interface defines the methods used to retrieve the properties of the configuration parameter.
ExampleModuleParameter
package com.javaeenotes.modules; // This enum class contains the parameters used to configure a module. public enum ExampleModuleParameter implements ModuleParameter { PARAM1("param1", true, "DEFAULT", "This is parameter 1."), PARAM2("param2", false, null, "This is parameter 2."); // Code below is boiler plate code. It is not possible to move the // code to an abstract class, because enumerators are not allowed // to extend classes. private final String name; private final boolean isRequired; private final String defaultValue; private final String explanation; private ExampleModuleParameter( String name, boolean isRequired, String defaultValue, String explanation) { this.name = name; this.isRequired = isRequired; this.defaultValue = defaultValue; this.explanation = explanation; } @Override public String toString() { return name; } @Override public boolean isRequired() { return isRequired; } @Override public String getDefaultValue() { return defaultValue; } @Override public String getExplanation() { return explanation; } }
Everything you should know about the specific parameters is defined here, and nowhere else (high cohesion)! It forces the developer who is adding new parameters to define the extra properties as required.
I tried to eliminate the boiler plate code, by moving them to a shared abstract enumeration class, but that is not possible. Now we have to copy the same code for every new "ModuleParameter" implementation. Is there a better solution for this?
Module
package com.javaeenotes.modules; public interface Module { void doBusinessStuff(); }
Just the interface of a module, containing a common method.
ExampleModule
package com.javaeenotes.modules; // Annotate this module with parameter class that contains the // configurable parameters for this module. @ParameterClass(parameterClass = ExampleModuleParameter.class) public class ExampleModule implements Module { private String param1; private String param2; @Override public void doBusinessStuff() { System.out.println("Hello, I am " + this.getClass().getSimpleName() + ". I am configured with param1='" + param1 + "', and param2='" + param2 + "'."); } public void setParam1(String param1) { this.param1 = param1; } public void setParam2(String param2) { this.param2 = param2; } }
This is an example implementation of a module. As you can see, it's just a POJO, making it very easy to write new ones. The class is linked to its parameter class using the annotation placed just before the class definition. The attributes and setters are important here. The names must correspond with the names defined in the parameter class.
ParameterClass
package com.javaeenotes.modules; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; // Only usable on classes. @Target(ElementType.TYPE) // Annotation must be available during runtime. @Retention(RetentionPolicy.RUNTIME) public @interface ParameterClass { Class<? extends ModuleParameter> parameterClass(); }
This is the annotation, which links the module to its parameter class. The factory uses this annotation to find out which configuration values to set.
ModuleType
package com.javaeenotes.modules; // This is basically a list of supported modules by the factory. public enum ModuleType { EXAMPLE_MODULE(ExampleModule.class); private Class<? extends Module> moduleClass; private ModuleType(Class<? extends Module> moduleClass) { this.moduleClass = moduleClass; } public Class<? extends Module> getModuleClass() { return moduleClass; } }
This enumeration is a list of supported modules. The purpose of this enumeration is to map the name to its class type. This information is required by the module factory to instantiate the correct module class.
ModuleFactory
package com.javaeenotes.modules; import java.util.Properties; public interface ModuleFactory { Module getModule(String moduleName, Properties configuration) throws Exception; }
The interface of the module factory. It requires the class name, and a "Properties" object containing the configuration of the module.
ModuleFactoryImpl
package com.javaeenotes.modules; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; import java.util.Properties; public class ModuleFactoryImpl implements ModuleFactory { private Map<String, ModuleType> nameToModuleType; public ModuleFactoryImpl() { // The Map contains all the supported module types. nameToModuleType = new HashMap<String, ModuleType>(); for (ModuleType moduleType : ModuleType.values()) { nameToModuleType.put( moduleType.getModuleClass().getSimpleName(), moduleType); } } @Override public Module getModule(String moduleName, Properties configuration) throws Exception { if (nameToModuleType.containsKey(moduleName)) { ModuleType moduleType = nameToModuleType.get(moduleName); Module module = moduleType.getModuleClass().newInstance(); configureModule(module, configuration); return module; } throw new IllegalArgumentException( "Class not supported: " + moduleName); } private void configureModule( Module module, Properties configuration) throws Exception { if (module.getClass().isAnnotationPresent(ParameterClass.class)) { Map<String, Method> methodNameToMethod = getMethodNameToMethod(module); for (ModuleParameter param : getModuleParameters(module)) { String configValue = configuration.getProperty( param.toString()); // Set default value if configuration value is empty. if (configValue.isEmpty()) { configValue = param.getDefaultValue(); } // Check if value is required. if ((configValue == null || configValue.isEmpty()) && param.isRequired()) { throw new IllegalArgumentException( "Configuration value missing for: " + param.toString()); } // Set configuration value in module. invokeParameterSetter(methodNameToMethod, module, param.toString(), configValue); } } } private Map<String, Method> getMethodNameToMethod(Module module) { Map<String, Method> methodNameToMethod = new HashMap<String, Method>(); for (Method method : module.getClass().getMethods()) { methodNameToMethod.put(method.getName(), method); } return methodNameToMethod; } private ModuleParameter[] getModuleParameters(Module module) { return module.getClass() .getAnnotation(ParameterClass.class) .parameterClass().getEnumConstants(); } private void invokeParameterSetter( Map<String, Method> methodNameToMethod, Module module, String paramName, String configValue) throws Exception { String setterMethodName = getSetterMethodName(paramName); if (methodNameToMethod.containsKey(setterMethodName)) { methodNameToMethod.get(setterMethodName).invoke( module, configValue); } else { throw new IllegalArgumentException( "No setter found for: " + paramName); } } private String getSetterMethodName(String paramName) { return "set" + Character.toUpperCase(paramName.charAt(0)) + paramName.substring(1); } }
Using the name of the module class, this factory instantiates the correct class with the help of the "ModuleType" enumeration class. Then it tries to find out if this module requires configuration, by detecting the annotation. The annotation specifies the use of the "ExampleModuleParameter" enumeration for configuration. Using the enumeration, and the "Properties" object, it calls the setters with the configuration values. It also checks if a required configuration value is present. The result is a module with all required configuration values set.
A drawback is that the method name of the setters is restricted. It must be the same as the name of the parameter (excluding the "set" string), which is defined in the enumeration. A solution for this is to use annotations in the setter method definition to specify which configuration parameter it corresponds to. Using string literals to tie things together makes the code brittle.
Main
package com.javaeenotes.modules; import java.util.Properties; public class Main { public static void main(String[] args) { // Build the configuration properties. Properties configuration = new Properties(); configuration.setProperty( ExampleModuleParameter.PARAM1.toString(), ""); configuration.setProperty( ExampleModuleParameter.PARAM2.toString(), "param2"); try { // Get instance of configured module from factory. Module module = new ModuleFactoryImpl() .getModule("ExampleModule", configuration); // Run the module. module.doBusinessStuff(); } catch (Exception e) { e.printStackTrace(); } } }
This class demonstrates the whole solution. If run, it prints: Hello, I am ExampleModule. I am configured with param1='DEFAULT', and param2='param2'.
Conclusion
As you can see, writing new modules is very easy by implementing just the two interfaces. All the configuration code is centralized in the factory implementation.
If you have ideas for improvements or better solutions, please don't hesitate to share them in the comments.
Hello:
ReplyDeleteFor translate the class name to the actual class in the factory why don't you use Class.forName() and inside the ModuleFactoryImpl().getModule() implementation you can concatenate the module name with the package info in order to get the fully qualified name.
You can use the class name to do that, but I've chosen this way because of the following reasons:
Delete- I want to decouple the implementation name from the logical parameter name. In a real case, the module name is a more 'end user friendly' name, and it's saved in the database. This way I can easily change the implementation if needed.
- I always try to avoid building the fully qualified name using Strings, because you have to hardcode the package name somewhere or there is other boilerplate code necessary to find out the package name.