Search This Blog

Loading...

Rusfond

Российский фонд помощи

Friday, January 1, 2010

Config framework

Table of contents




Rationale


It's rather common concept to have a single shared place to monitor and manage application settings. I.e. provide access to current application settings values, allow to modify them and provide property value change notification for all interested classes. It's also convenient to allow to modify the settings externally. Of course it's possible to expose them via MBeans (MXBeans) but sometimes we want to have a more simply way, say, allow to modify a text file and get the changes processed on the fly.

I created mini-framework that solves the task and want to share it since it's not coupled to any particular application and may be reused everywhere.



Source code


Framework's source code and usage example may be downloaded here

Use-cases


Here is a simple uml use-case diagram for the framework:



Let's look at the diagram above - the main framework aims are:

  • provide access to the current settings values;

  • allow to receive notification about settings values change;

  • allow to modify settings values;



The last aim ('values modification') is provided in two forms:

  • configure external property file(s) with settings values and pickup the changes from them;

  • set value programmatically (it's possible to configure if such a value overrides the one detected at external config);



Main interfaces


Here are the main framework interfaces.

ConfigProperty.java

package org.springcontrib.config;

/**
* This interface defines general contract of config property. Config property is uniquely identifiable
* entity which value type is upper-bound. I.e. it's possible to define property type as, say, {@link Number}
* ans use various subclasses like {@link Integer}, {@link Long} etc as an actual value.
*
* @author Denis Zhdanov
* @since Oct 22, 2009
*/

public interface ConfigProperty {

/**
* @return the key used at the application config to represent current property
*/

String getConfigKey();

/**
* @return property value type
*/

Class<?> getTargetType();

/**
* Just a complement to java generics erasure - covers type-cast under the hood.
* <p/>
* Another responsibility of this method is to check that given value conforms to <code>IS-A</code>
* relationship to the class defined by {@link #getTargetType()}.
* <p/>
* <b>Note:</b> <code>null</code> is a special value as it returned as is from this method.
*
* @param value value to convert
* @param <T> target value type
* @return given value typed to the target type
* @throws IllegalArgumentException if given value doesn't have <code>IS-A</code> relationship to the class
* defined by {@link #getTargetType()}
*/

@SuppressWarnings({"unchecked", "SuppressionAnnotation"})
<T> T convert(Object value) throws IllegalArgumentException;
}


ConfigPropertyListener.java

package org.springcontrib.config;

import java.util.Set;

/**
* Defines general contract for the callback that should be notified on target property change.
*
* @author Denis Zhdanov
* @since Oct 22, 2009
*/

public interface ConfigPropertyListener<T extends Enum<T> & ConfigProperty> {

/**
* Is called when given property value is changed.
* <p/>
* Type safety may be preserved using the following approach:
* <pre>
* public class MyListener implements ConfigPropertyListener{@code <MyProperty>} {
* public void propertyChanged(MyProperty property, Object oldValue, Object newValue) {
* Integer typedOldValue = property.convert(oldValue);
* Integer typedNewValue = property.convert(newValue);
* }
*
* public Set{@code <T>} getInterestedProperties() {
* return Collections.singleton(MyProperty.PROPERTY1);
* }
* }
* </pre>
* <p/>
* <b>Note:</b> remember that either old or new value may be <code>null</code>, so, don't tempt to use them
* as a primitive type values in order to avoid NPE due to autounboxing.
*
* @param property changed property
* @param oldValue old value if any; <code>null</code> otherwise
* @param newValue new value if any; <code>null</code> otherwise
*/

void propertyChanged(T property, Object oldValue, Object newValue);

/**
* @return properties that are interested for the current listener
*/

Set<T> getInterestedProperties();
}


PropertiesLoader.java

package org.springcontrib.config;

import java.io.IOException;
import java.util.Collection;
import java.util.Properties;

/**
* Define general contract for the class that allows to retrieve properties defined at the target resource(s)
* as a {@link Properties} object any time.
*
* @author Denis Zhdanov
* @since Oct 24, 2009
*/

public interface PropertiesLoader {

/**
* Allows to retrieve properties defined at standard <code>'java properties'</code> format at the resource
* set via {@link #setResourceLocation(String)}.
*
* @throws IOException in the case of unexpected I/O problem during properties processing
* @throws IllegalStateException if necessary properties are undefined for the current object
* @return merged properties stored at the target resources;
* empty <code>Properties</code> object if no resource locations are specified;
*/

Properties load() throws IOException, IllegalStateException;

/**
* Allows to define target resource at the format described at the
* <a href="http://static.springsource.org/spring/docs/3.0.x/spring-framework-reference/html/ch04.html">
* Spring Reference</a>.
*
* @param resourceLocation target resource location
*/

void setResourceLocation(String resourceLocation);

/**
* Allows to define target resources where every resource string has a format described at the
* <a href="http://static.springsource.org/spring/docs/3.0.x/spring-framework-reference/html/ch04.html">
* Spring Reference</a>.
*
* @param resourceLocations target resources location
*/

void setResourceLocations(Collection<String> resourceLocations);
}


Config.java

package org.springcontrib.config;

/**
* Defines general contract for the application settings retrieval.
*
* @author Denis Zhdanov
* @since Oct 22, 2009
* @param <T> config property type
*/

public interface Config<T extends Enum<T> & ConfigProperty> {

/**
* Allows to retrieve value of the given property.
* <p/>
* This method behaves at the same way as {@link #getValue(Enum, Object)} with <code>'null'</code>
* as the second argument.
* <p/>
* <b>Note:</b> be sure not to use this method where return value is statically typed to the primitive type
* in order to not getValue <code>NPE</code> because of autounboxing.
*
* @param property target property
* @param <R> expected return type
* @return target property value if any is defined; <code>null</code> otherwise
*/

<R> R getValue(T property);

/**
* Allows to retrieve value of the given property.
* <p/>
* Returned value type conforms to <code>IS-A</code> relation to the property type defined
* via {@link ConfigProperty#getTargetType()}.
*
* @param property target property
* @param defaultValue default value to use if no value is defined for the givne property
* @param <R> expected return type
* @return target property value if any is defined; given value otherwise
*/

<R> R getValue(T property, R defaultValue);
}


Usage example



Let's define test enum which members are used as our application properties:


package org;

import org.springcontrib.config.ConfigProperty;

/**
* @author Denis Zhdanov
* @since Dec 30, 2009
*/

public enum MyAppProperty implements ConfigProperty {
COUNTER(Long.class, "app.counter"),
TITLE(String.class, "app.title");

private final Class<?> targetClass;
private final String configKey;

MyAppProperty(Class<?> targetClass, String configKey) {
this.targetClass = targetClass;
this.configKey = configKey;
}

@Override
public String getConfigKey() {
return configKey;
}

@Override
public Class<?> getTargetType() {
return targetClass;
}

@SuppressWarnings({"unchecked"})
@Override
public <T> T convert(Object value) throws IllegalArgumentException {
if (value == null) {
return null;
}
if (!targetClass.isAssignableFrom(value.getClass())) {
throw new IllegalArgumentException(String.format("Can't convert given value (%s) of %s property "
+ "to the target type. Reason: there is no IS-A relation between given value class (%s) "
+ "and target class (%s)", value, this, value.getClass(), targetClass));
}
return (T) value;
}
}


Couple of property value change listener classes (note that they define different sets of interested properties):


package org;

import org.springcontrib.config.ConfigPropertyListener;
import org.springframework.stereotype.Component;

import java.util.Set;
import java.util.Collections;

/**
* @author Denis Zhdanov
* @since Dec 30, 2009
*/

@Component
public class CounterPropertyListener implements ConfigPropertyListener<MyAppProperty> {

@Override
public void propertyChanged(MyAppProperty property, Object oldValue, Object newValue) {
Long counter = MyAppProperty.COUNTER.convert(newValue);
System.out.println("Counter changed: " + counter);
}

@Override
public Set<MyAppProperty> getInterestedProperties() {
return Collections.singleton(MyAppProperty.COUNTER);
}
}



package org;

import org.springcontrib.config.ConfigPropertyListener;
import org.springframework.stereotype.Component;

import java.util.Set;
import java.util.EnumSet;

/**
* @author Denis Zhdanov
* @since Dec 30, 2009
*/

@Component
public class GenericPropertyListener implements ConfigPropertyListener<MyAppProperty> {

@Override
public void propertyChanged(MyAppProperty property, Object oldValue, Object newValue) {
System.out.printf("Detected change of the property '%s': '%s' -> '%s'%n", property, oldValue, newValue);
}

@Override
public Set<MyAppProperty> getInterestedProperties() {
return EnumSet.allOf(MyAppProperty.class);
}
}


Config refresher class:


package org;

import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springcontrib.config.impl.ConfigImpl;

import javax.annotation.PostConstruct;

/**
* @author Denis Zhdanov
* @since Dec 30, 2009
*/

@Component
public class ConfigRefresher {

private final ConfigImpl config;

@Autowired
public ConfigRefresher(ConfigImpl config) {
this.config = config;
}

@PostConstruct
public void start() {
new Thread() {
@Override
public void run() {
while (true) {
config.refresh();
try {
sleep(300);
} catch (InterruptedException e) {
return;
}
}
}
}.start();
}
}


All we need to do now is to assemble spring config:


<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd"
>

<context:component-scan base-package="org"/>

<bean class="org.springcontrib.config.impl.ConfigImpl">
<constructor-arg value="org.MyAppProperty"/>
<property name="resourceLocations">
<list>
<value>counter.properties</value>
<value>title.properties</value>
</list>
</property>
</bean>

</beans>


Bootstrap class:


package org;

import org.springframework.context.support.ClassPathXmlApplicationContext;

/**
* @author Denis Zhdanov
* @since Nov 4, 2009
*/

public class SpringContribStartClass {
public static void main(String[] args) throws Exception {
new ClassPathXmlApplicationContext("spring-config.xml");
}
}


Also we want to put two config files at classpath:

counter.properties

app.counter=0


title.properties

app.title=cool application


We see that registered listeners receive notifications about application properties values change if we start the application (you can execute 'mvn install' action vs attached maven project to get the application started as well). It's also possible to define particular values programmatically (e.g. to make particular properties optional at external config file and provide default values for them).

Implementation



DefaultConfigPropertyConvertersProvider.java

package org.springcontrib.config.impl;

import org.springframework.core.convert.converter.Converter;

import java.io.File;
import java.util.HashSet;
import java.util.Set;

/**
* Allows to retrieve 'built-in' config property converters.
*
* @author Denis Zhdanov
* @since Oct 22, 2009
*/

public class DefaultConfigPropertyConvertersProvider {

/**
* @return 'built-in' config property converters
*/

public Set<Converter<String, ?>> getConverters() {
Set<Converter<String, ?>> result = new HashSet<Converter<String, ?>>();
result.add(new BooleanConverter());
result.add(new CharacterConverter());
result.add(new DoubleConverter());
result.add(new FileConverter());
result.add(new FloatConverter());
result.add(new IntConverter());
result.add(new LongConverter());
result.add(new StringConverter());
return result;
}

// The purpose of the classes below is to provide argument string trimming.

private static class BooleanConverter implements Converter<String, Boolean> {
@Override
public Boolean convert(String source) {
return source == null ? null : Boolean.valueOf(source.trim());
}
}

private static class CharacterConverter implements Converter<String, Character> {
@Override
public Character convert(String source) throws IllegalArgumentException {
if (source == null) {
return null;
}

String trimmed = source.trim();
if (trimmed.length() != 1) {
throw new IllegalArgumentException(String.format("Can't convert given value ('%s') to char. "
+ "Reason: given string length is invalid - expected 1, actual %d", source, source.length()));
}
return trimmed.charAt(0);
}
}

private static class DoubleConverter implements Converter<String, Double> {
@Override
public Double convert(String source) throws NumberFormatException {
return source == null ? null : Double.valueOf(source.trim());
}
}

private static class FileConverter implements Converter<String, File> {
@Override
public File convert(String source) {
return source == null ? null : new File(source.trim());
}
}

private static class FloatConverter implements Converter<String, Float> {
@Override
public Float convert(String source) throws NumberFormatException {
return source == null ? null : Float.valueOf(source.trim());
}
}

private static class IntConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) throws NumberFormatException {
return source == null ? null : Integer.valueOf(source.trim());
}
}

private static class LongConverter implements Converter<String, Long> {
@Override
public Long convert(String source) throws NumberFormatException {
return source == null ? null : Long.valueOf(source.trim());
}
}

private static class StringConverter implements Converter<String, String> {
@Override
public String convert(String source) {
return source == null ? null : source.trim();
}
}
}


DefaultPropertiesLoader.java

package org.springcontrib.config.impl;

import org.springcontrib.config.PropertiesLoader;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;

/**
* Default {@link PropertiesLoader} implementation.
* <p/>
* It's main purpose is to try to re-read target properties whenever possible. For example, we can specify
* target properties location as a classpath resource but there is a possible case that current environment
* caches classpath resources (e.g. Tomcat has such a behavior). So, this class attempts to resolve that via
* trying to match target resource to file system and perform <code>'clean'</code> re-read.
* <p/>
* Thread-safe.
*
* @author Denis Zhdanov
* @since Oct 24, 2009
*/

public class DefaultPropertiesLoader implements PropertiesLoader, ApplicationContextAware {

private final AtomicReference<ResourceLoader> resourceLoader = new AtomicReference<ResourceLoader>();
private final Collection<Resource> resources = new CopyOnWriteArrayList<Resource>();
private final Collection<String> resourceLocations = new CopyOnWriteArrayList<String>();
private final Collection<String> resourceLocationsView = Collections.unmodifiableCollection(resourceLocations);

/** Holds cached resource datas if any. */
private final ConcurrentMap<Resource, Properties> resourcesDataCache = new ConcurrentHashMap<Resource, Properties>();

/** Holds last resource content check time for file system-based resources. */
private final ConcurrentMap<Resource, Long> resourceModificationTimes = new ConcurrentHashMap<Resource, Long>();

/**
* Allows to retrieve properties defined at standard <code>'java properties'</code> format at the resource
* set via {@link #setResourceLocation(String)}.
* <p/>
* Note that it's possible that more than one target property path is provided to this class and there
* is a possibility of properties overlapping. Value of the last processed resource wins then.
* Resources iteration order is the same as the one defined by resource locations collection given to
* {@link #setResourceLocations(Collection)} method.
*
* @throws IOException in the case of unexpected I/O problem during properties processing
* @throws IllegalStateException if 'resourceLoader' property is undefined
* @return merged properties stored at the target resources;
* empty <code>Properties</code> object if no resource locations are specified;
*/

@Override
public Properties load() throws IOException, IllegalStateException {
if (resourceLoader.get() == null) {
throw new IllegalStateException("Can't load target properties. Reason: 'resourceLoader' property "
+ "is undefined");
}
Properties result = new Properties();
for (Resource resource : resources) {
result.putAll(load(resource));
}
return result;
}

/**
* @return resource locations view
*/

public Collection<String> getResourceLocations() {
return resourceLocationsView;
}

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
resourceLoader.set(applicationContext);
}

@Override
public void setResourceLocation(String resourceLocation) {
setResourceLocations(Collections.singletonList(resourceLocation));
}

@Override
public void setResourceLocations(Collection<String> resourceLocations) {
this.resourceLocations.clear();
this.resourceLocations.addAll(resourceLocations);
resolveResourcesIfNecessary();
}

/**
* Allows to define resource loader to use for performing <code>'resource url' -> {@link Resource} object</code>
* transformation.
*
* @param resourceLoader resource loader to use
*/

public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader.set(resourceLoader);
resolveResourcesIfNecessary();
}

/**
* Maps configured resource locations to {@link Resource} objects if wither resource locations
* or resource loader are defined.
*/

private void resolveResourcesIfNecessary() {
if (resourceLocations.isEmpty()) {
return;
}
ResourceLoader resourceLoaderToUse = resourceLoader.get();
if (resourceLoaderToUse == null) {
return;
}
Collection<Resource> resources = new ArrayList<Resource>(resourceLocations.size());
for (String resourceLocation : resourceLocations) {
Resource resource = resourceLoaderToUse.getResource(resourceLocation);
try {
File file = resource.getFile();
FileSystemResource resourceToUse = new FileSystemResource(file);
resources.add(resourceToUse);
resourceModificationTimes.put(resourceToUse, -1L);
} catch (IOException e) {
resources.add(resource);
}
}
this.resources.clear();
this.resources.addAll(resources);
}

private Properties load(Resource resource) throws IOException {
Properties cached = resourcesDataCache.get(resource);
Long lastCheckTime = resourceModificationTimes.get(resource);
if (cached != null
&& (lastCheckTime == null || resource.getFile().lastModified() <= lastCheckTime))
{
return cached;
}

InputStream inputStream = resource.getInputStream();
Properties result = new Properties();
try {
result.load(inputStream);
} finally {
inputStream.close();
}

// If resource is resolved to file system resource.
if (lastCheckTime != null) {
resourceModificationTimes.put(resource, resource.getFile().lastModified());
}
resourcesDataCache.put(resource, result);
return result;
}
}


ConfigImpl.java

package org.springcontrib.config.impl;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springcontrib.config.Config;
import org.springcontrib.config.ConfigProperty;
import org.springcontrib.config.ConfigPropertyListener;
import org.springcontrib.config.PropertiesLoader;
import org.springcontrib.util.generic.compliance.CompositeTypeComplianceMatcher;
import org.springcontrib.util.generic.compliance.TypeComplianceMatcher;
import org.springcontrib.util.generic.resolver.DefaultTypeArgumentResolver;
import org.springcontrib.util.generic.resolver.TypeArgumentResolver;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.io.ResourceLoader;

import java.io.IOException;
import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
* Default {@link Config} implementation. Supports {@link ConfigPropertyListener}'s notification and management.
* <p/>
* Allows to automatically pickup config listeners registered at spring context. That behavior may be customized
* via {@link #setPickupListenersFromContext(boolean) pickupListenersFromContext} property.
* <p/>
* Note that this class uses commons-logging LOGGER but the only reason for that is that Spring uses it itself.
* <p/>
* Thread-safe.
*
* @author Denis Zhdanov
* @since Oct 22, 2009
*/

public class ConfigImpl<T extends Enum<T> & ConfigProperty>
implements Config<T>, ApplicationContextAware, ResourceLoaderAware
{

private static final Log LOGGER = LogFactory.getLog(ConfigImpl.class);

/** Listeners sorted by target properties. */
private final ConcurrentMap<T, Set<ConfigPropertyListener<T>>> listeners
= new ConcurrentHashMap<T, Set<ConfigPropertyListener<T>>>();

/** Cached property values that are retrieved from the underlying message source. */
private final ConcurrentMap<T, Object> dynamicValues = new ConcurrentHashMap<T, Object>();

/** Property values that are registered manually via {@link #setValue(Enum, Object, boolean)}. */
private final ConcurrentMap<T, Object> staticValues = new ConcurrentHashMap<T, Object>();
/** Holds flags that determine 'override policy' for the static values stored at {@link #staticValues} */
private final ConcurrentMap<T, Boolean> staticValuesOverrideRules = new ConcurrentHashMap<T, Boolean>();

private final ConcurrentMap<Type, Converter<String, ?>> converters
= new ConcurrentHashMap<Type, Converter<String, ?>>();

private final AtomicReference<PropertiesLoader> customPropertiesLoader = new AtomicReference<PropertiesLoader>();
private final DefaultPropertiesLoader defaultPropertiesLoader = new DefaultPropertiesLoader();
private final AtomicReference<TypeArgumentResolver> typeArgumentResolver
= new AtomicReference<TypeArgumentResolver>(DefaultTypeArgumentResolver.INSTANCE);
private final AtomicReference<TypeComplianceMatcher<Type>> typeComplianceMatcher
= new AtomicReference<TypeComplianceMatcher<Type>>(CompositeTypeComplianceMatcher.INSTANCE);
private final AtomicBoolean pickupListenersFromContext = new AtomicBoolean(true);

private final Set<T> propertyKeys;
private final Class<T> targetPropertyClass;

/**
* Constructs new <code>ConfigImpl</code> object.
*
* @param clazz class of the target application property enum
* @throws IllegalArgumentException if given argument is null
*/

public ConfigImpl(Class<T> clazz) throws IllegalArgumentException {
if (clazz == null) {
throw new IllegalArgumentException("Can't create ConfigImpl object. Reason: target property class "
+ "argument is null");
}
propertyKeys = EnumSet.allOf(clazz);
addConverters(new DefaultConfigPropertyConvertersProvider().getConverters());
targetPropertyClass = clazz;
}

@SuppressWarnings({"RedundantTypeArguments"})
@Override
public <R> R getValue(T property) {
return this.<R>getValue(property, null);
}

/**
* Returns target property value if any is defined.
* <p/>
* There is a possible case that particular property value is picked up during {@link #refresh()} call
* and registered manually via {@link #setValue(Enum, Object, boolean)}. <code>'Override'</code> flag
* used at the {@link #setValue(Enum, Object, boolean)} determines what value is returned.
*
* @param property target property
* @param defaultValue default value to use if no value is defined for the givne property
* @param <V> target value type
* @return target property value if defined; given default value otherwise
*/

@SuppressWarnings({"unchecked"})
@Override
public <V> V getValue(T property, V defaultValue) {
Object result;
if (staticValuesOverrideRules.containsKey(property)) {
if (staticValuesOverrideRules.get(property)) {
result = staticValues.get(property);
} else {
result = dynamicValues.containsKey(property) ? dynamicValues.get(property) : staticValues.get(property);
}
} else {
result = dynamicValues.get(property);
}

if (result == null) {
return defaultValue;
}
return (V)property.convert(result);
}

/**
* Allows to refresh current config, i.e. pickup the newest property values and notify registered listeners
* if necessary.
*/

public void refresh() {
Properties properties;
try {
properties = getPropertiesLoader().load();
} catch (IOException e) {
LOGGER.error("Unexpected I/O exception occurred at the attempt to refresh properties", e);
return;
}
for (T property : propertyKeys) {
String typedValue = properties.getProperty(property.getConfigKey());
if (typedValue == null || typedValue.trim().isEmpty()) {
continue;
}
Converter<String, ?> converter = converters.get(property.getTargetType());
if (converter == null) {
LOGGER.error(String.format("No property value converter is configured for the type %s. "
+ "Following converters are registered: %s", property.getTargetType(), converters));
continue;
}
Object newValue;
try {
newValue = converter.convert(typedValue);
} catch (Exception e) {
LOGGER.error(String.format("Detected invalid value for property %s (config key '%s') detected: '%s'. "
+ "It can't be converted to the target type (%s). Skipping...",
property, property.getConfigKey(), typedValue, property.getTargetType()), e);
continue;
}
Object previousValue = dynamicValues.put(property, newValue);
if ((previousValue == null && newValue != null)
|| (previousValue != null && !previousValue.equals(newValue))) {
notifyListeners(property, previousValue, newValue);
}
}
}

/**
* Allows to manually register value for the given property.
* <p/>
* <b>Note:</b> this method may be used to reset previously set value if necessary. All that is need for that is
* to provide <code>null</code> as a property value.
*
* @param property target property
* @param value target property value
* @param override allows to define if given property value should override the one found at config if any
* @param <V> target value type
* @throws IllegalArgumentException if given value doesn't conform to <code>IS-A</code> relation to the target
* value type defined by te given property
* ({@link ConfigProperty#getTargetType()})
*/

public <V> void setValue(T property, V value, boolean override) throws IllegalArgumentException {
property.convert(value); // check type

Object oldValue = getValue(property);

if (value == null) {
staticValues.remove(property);
staticValuesOverrideRules.remove(property);
} else {
staticValues.put(property, value);
staticValuesOverrideRules.put(property, override);
}

// Avoid listeners notification if dynamic value registered for the given property and given static value
// doesn't override it.
if (!override && dynamicValues.containsKey(property)) {
return;
}

notifyListeners(property, oldValue, value);
}

/**
* Allows to customize property value converters. I.e. converters provided by
* {@link DefaultConfigPropertyConvertersProvider} are used by default but it's possible to replace/expand
* them via this method.
* <p/>
* Target property type is derived from {@link Converter} generic type, e.g. if particular class
* implements Converter{@code <String, Integer>} that means it should be used for
* <code>Integer</code> properties conversion.
*
* @param converters converters to replace/expand the list of currently defined converters
*/

public void addConverters(Iterable<Converter<String, ?>> converters) {
for (Converter<String, ?> converter : converters) {
registerConverter(converter);
}
}

@SuppressWarnings({"unchecked"})
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
if (!pickupListenersFromContext.get()) {
return;
}

for (ConfigPropertyListener<?> listener : applicationContext.getBeansOfType(ConfigPropertyListener.class).values()) {
Type listenerArgType = typeArgumentResolver.get().resolve(
ConfigPropertyListener.class, listener.getClass(), 0
);
if (!typeComplianceMatcher.get().match(listenerArgType, targetPropertyClass)) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("Listener of class %s defined at spring context is not automatically "
+ "registered because it's generic parameter type value is not compatible to the "
+ "target property type (%s)", listener.getClass(), targetPropertyClass));
}
continue;
}
addConfigPropertyListeners((ConfigPropertyListener<T>) listener);
}
}

/**
* Allows to register given listeners to be notified on config property values change.
*
* @param listeners listeners to register
*/

public void addConfigPropertyListeners(ConfigPropertyListener<T> ... listeners) {
for (ConfigPropertyListener<T> listener : listeners) {
for (T property : listener.getInterestedProperties()) {
Set<ConfigPropertyListener<T>> listenersByProperty = this.listeners.get(property);
if (listenersByProperty == null) {
this.listeners.put(property, listenersByProperty = new CopyOnWriteArraySet<ConfigPropertyListener<T>>());
}
listenersByProperty.add(listener);
}
}

}

/**
* Allows to replace currently registered config property listeners with the given listeners.
*
* @param listeners listeners to use
*/

public void setConfigPropertyListeners(ConfigPropertyListener<T> ... listeners) {
this.listeners.clear();
addConfigPropertyListeners(listeners);
}

/**
* Allows to deregister all of the given config property listeners.
*
* @param listeners listeners to deregister
*/

public void removeConfigPropertyListeners(ConfigPropertyListener<T> ... listeners) {
for (Set<ConfigPropertyListener<T>> listenersSet : this.listeners.values()) {
for (ConfigPropertyListener<T> listener : listeners) {
listenersSet.remove(listener);
}
}
}

@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
defaultPropertiesLoader.setResourceLoader(resourceLoader);
}

/**
* Allows to define resource locations of the target config files.
* <p/>
* Their syntax is assumed to be the same as the one used for spring resources, i.e. default loading mechanism
* is used by application context type but may be specified by particular resource type prefix, e.g.
* <code>'classpath:'</code>.
* <p/>
* Given resources should be resolved to file system resource in order to be reloadable. That means that
* it's possible to define classpath-based resource and have it being reloadable if it's resolved against
* the file system but classpath resource is not reloadable if it is, say, packaged to the war file.
*
* @param locations target resource locations
*/

public void setResourceLocations(Collection<String> locations) {
defaultPropertiesLoader.setResourceLocations(locations);
}

/**
* Allows to define properties loader used for 'raw' properties loading.
* <p/>
* This property is not mandatory, i.e. default properties loader is used if no custom one is defined.
*
* @param propertiesLoader properties loader to use
*/

public void setPropertiesLoader(PropertiesLoader propertiesLoader) {
customPropertiesLoader.set(propertiesLoader);
}

/**
* Allows to override {@link TypeArgumentResolver} used by this class internally for filtering config listeners
* and working with property value converters.
* <p/>
* Note that this property is not mandatory and {@link DefaultTypeArgumentResolver#INSTANCE} is used by default
*
* @param typeArgumentResolver type argument resolver to use
*/

public void setTypeArgumentResolver(TypeArgumentResolver typeArgumentResolver) {
this.typeArgumentResolver.set(typeArgumentResolver);
}

/**
* Allows to define {@link TypeComplianceMatcher} to use during the processing.
* <p/>
* {@link CompositeTypeComplianceMatcher#INSTANCE} is used by default.
*
* @param matcher custom type compliance matcher to use
*/

public void setTypeComplianceMatcher(TypeComplianceMatcher<Type> matcher) {
typeComplianceMatcher.set(matcher);
}

/**
* Allows to define if the registered beans of class {@link ConfigPropertyListener} should be picked up from
* spring context during initialization and automatically registered.
* <p/>
* Default value is <code>'true'</code>.
* <p/>
* <b>Note:<b> the listeners are filtered based on its type parameter value if possible. I.e. type parameter
* value is intended to be compatible to the target property type given to the current class constructor.
*
* @param pickupListenersFromContext flag that shows if the listeners registered at spring context should
* be automatically registered
*/

public void setPickupListenersFromContext(boolean pickupListenersFromContext) {
this.pickupListenersFromContext.set(pickupListenersFromContext);
}

/**
* Used by this class internally to retrieve properties loader to use for <code>'raw'</code> properties loading.
*
* @return properties loader to use for <code>'raw'</code> properties loading
*/

protected PropertiesLoader getPropertiesLoader() {
PropertiesLoader result = customPropertiesLoader.get();
return result == null ? defaultPropertiesLoader : result;
}

private void registerConverter(Converter<String, ?> converter) {
Type targetType = typeArgumentResolver.get().resolve(Converter.class, converter.getClass(), 1);
Converter<String, ?> previous = converters.put(targetType, converter);
if (previous != null && LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("Property value converter for the '%s' type is changed. Old: %s, current: %s",
targetType, previous, converter));
}
}

private void notifyListeners(T property, Object oldValue, Object newValue) {
Set<ConfigPropertyListener<T>> listenersToNotify = listeners.get(property);
if (listenersToNotify == null) {
return;
}
for (ConfigPropertyListener<T> listener : listenersToNotify) {
listener.propertyChanged(property, oldValue, newValue);
}
}
}


Tests



ConfigImplTest.java

package org.springcontrib.config.impl;

import org.jmock.Expectations;
import org.jmock.Mockery;
import org.jmock.integration.junit4.JMock;
import org.jmock.integration.junit4.JUnit4Mockery;
import org.jmock.lib.legacy.ClassImposteriser;
import org.junit.After;
import static org.junit.Assert.assertEquals;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springcontrib.config.ConfigProperty;
import org.springcontrib.config.ConfigPropertyListener;
import org.springcontrib.config.PropertiesLoader;
import org.springframework.context.ApplicationContext;
import org.springframework.core.convert.converter.Converter;

import java.io.IOException;
import java.util.*;

/**
* @author Denis Zhdanov
* @since 12/20/2009
*/

@RunWith(JMock.class)
public class ConfigImplTest {

private final Properties dynamicProperties = new Properties();
private ConfigImpl<TestProperty> config;
private Mockery mockery;
private ConfigPropertyListener<TestProperty> listener;
private ApplicationContext applicationContext;

@SuppressWarnings({"unchecked"})
@Before
public void setUp() throws IOException {
config = new ConfigImpl<TestProperty>(TestProperty.class);
mockery = new JUnit4Mockery() {{
setImposteriser(ClassImposteriser.INSTANCE);
}};

final PropertiesLoader propertiesLoader = mockery.mock(PropertiesLoader.class);
mockery.checking(new Expectations() {{
allowing(propertiesLoader).load();
will(returnValue(dynamicProperties));
}});
config.setPropertiesLoader(propertiesLoader);

listener = mockery.mock(ConfigPropertyListener.class);
mockery.checking(new Expectations() {{
allowing(listener).getInterestedProperties();
will(returnValue(EnumSet.allOf(TestProperty.class)));
}});

applicationContext = mockery.mock(ApplicationContext.class);
}

@After
public void checkExpectations() {
mockery.assertIsSatisfied();
}

@Test
public void staticValueOverride() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "dynamic-first");
dynamicProperties.setProperty(TestProperty.SECOND.getConfigKey(), "dynamic-second");
config.setValue(TestProperty.FIRST, "static-first", true);
config.refresh();
config.setValue(TestProperty.SECOND, "static-second", true);
assertEquals("static-first", config.<Object>getValue(TestProperty.FIRST));
assertEquals("static-second", config.<Object>getValue(TestProperty.SECOND));
}

@Test
public void staticValuesFallback() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "dynamic-first");
dynamicProperties.setProperty(TestProperty.SECOND.getConfigKey(), "dynamic-second");
config.setValue(TestProperty.FIRST, "static-first", false);
config.refresh();
config.setValue(TestProperty.SECOND, "static-second", false);
assertEquals("dynamic-first", config.<Object>getValue(TestProperty.FIRST));
assertEquals("dynamic-second", config.<Object>getValue(TestProperty.SECOND));
}

@Test
public void staticValueReset() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "dynamic-first");
dynamicProperties.setProperty(TestProperty.SECOND.getConfigKey(), "dynamic-second");
config.setValue(TestProperty.FIRST, "static-first", true);
config.refresh();
config.setValue(TestProperty.FIRST, null, true);
config.setValue(TestProperty.SECOND, null, true);
assertEquals("dynamic-first", config.<Object>getValue(TestProperty.FIRST));
assertEquals("dynamic-second", config.<Object>getValue(TestProperty.SECOND));
}

@Test
public void listenerNotificationOnDynamicValueChange() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "v1");
mockery.checking(new Expectations() {{
one(listener).propertyChanged(TestProperty.FIRST, "v1", "v2");
}});
config.refresh();
config.addConfigPropertyListeners(listener);
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "v2");
config.refresh();
}

@Test
public void listenerNotificationOnStaticValueChange() {
mockery.checking(new Expectations() {{
one(listener).propertyChanged(TestProperty.FIRST, null, "v1");
one(listener).propertyChanged(TestProperty.FIRST, "v1", "v2");
}});
config.addConfigPropertyListeners(listener);
config.setValue(TestProperty.FIRST, "v1", true);
config.setValue(TestProperty.FIRST, "v2", false);
}

@Test
public void listenerNotificationOnMixedPropertyChange() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "f1");
dynamicProperties.setProperty(TestProperty.SECOND.getConfigKey(), "s1");
config.refresh();
mockery.checking(new Expectations() {{
one(listener).propertyChanged(TestProperty.SECOND, "s1", "s2");
one(listener).propertyChanged(TestProperty.FIRST, "f1", "f3");
}});
config.addConfigPropertyListeners(listener);
config.setValue(TestProperty.FIRST, "f2", false);
config.setValue(TestProperty.SECOND, "s2", true);
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "f3");
config.refresh();
}

@Test
public void listenerRemoval() {
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "f1");
mockery.checking(new Expectations() {{
one(listener).propertyChanged(TestProperty.FIRST, null, "f1");
}});
config.addConfigPropertyListeners(listener);
config.refresh();

// Expecting that the listener is not called on property change after listener removal

config.removeConfigPropertyListeners(listener);
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "f2");
config.refresh();
}

@SuppressWarnings({"unchecked"})
@Test
public void customConvertersProcessing() {
final Runnable mock = mockery.mock(Runnable.class);
mockery.checking(new Expectations() {{
atLeast(1).of(mock).run();
}});

// We need to explicitly define a class that holds target property type information at the static data level.
// So, we create such a class that just delegates to the mock object in order to check the processing.
Converter<String, String> converter = new Converter<String, String>() {
@Override
public String convert(String source) {
mock.run();
return source;
}
};
config.addConverters(Collections.<Converter<String, ?>>singleton(converter));
dynamicProperties.setProperty(TestProperty.FIRST.getConfigKey(), "v");
config.refresh();
}

@Test
public void noAutomaticListenersPickup() {
config.setPickupListenersFromContext(false);

// Don't expect automatic listeners pickup from the application context.
config.setApplicationContext(applicationContext);
}

@Test
public void listenersFilteringOnAutomaticPickup() {
// We define listener anonymous classes explicitly in order to store target property information
// at the static data level.
ConfigPropertyListener<TestProperty> listener1 = new ConfigPropertyListener<TestProperty>() {
@Override
public void propertyChanged(TestProperty property, Object oldValue, Object newValue) {
listener.propertyChanged(property, oldValue, newValue);
}

@Override
public Set<TestProperty> getInterestedProperties() {
return listener.getInterestedProperties();
}
};
ConfigPropertyListener<AnotherProperty> listener2 = new ConfigPropertyListener<AnotherProperty>() {
@Override
public void propertyChanged(AnotherProperty property, Object oldValue, Object newValue) {
}

@Override
public Set<AnotherProperty> getInterestedProperties() {
return null;
}
};

// Register 'listener1' automatically.
final Map<String, ConfigPropertyListener<?>> listeners = new HashMap<String, ConfigPropertyListener<?>>();
listeners.put("l1", listener1);
listeners.put("l2", listener2);
mockery.checking(new Expectations() {{
one(applicationContext).getBeansOfType(ConfigPropertyListener.class);will(returnValue(listeners));
}});
config.setApplicationContext(applicationContext);

// Check that 'listener1' is registered.
mockery.checking(new Expectations() {{
one(listener).propertyChanged(TestProperty.FIRST, null, "v");
}});
config.setValue(TestProperty.FIRST, "v", true);
}

private enum TestProperty implements ConfigProperty {
FIRST, SECOND;

@Override
public String getConfigKey() {
return toString();
}

@Override
public Class<?> getTargetType() {
return String.class;
}

@SuppressWarnings({"unchecked"})
@Override
public <T> T convert(Object value) throws IllegalArgumentException {
return value == null ? null : (T) value.toString();
}
}


enum AnotherProperty implements ConfigProperty {
A, B;

@Override
public String getConfigKey() {
return toString();
}

@Override
public Class<?> getTargetType() {
return String.class;
}

@Override
public <T> T convert(Object value) throws IllegalArgumentException {
return null;
}
}
}


Notes


Couple of notes regarding the framework:

  • 'setValue()' method is defined at ConfigImpl class (not Config interface) in assumption that most of config clients only need to access the values, i.e. direct ConfigImpl type may be used at the classes that perform programmatic property value change;

  • the framework uses spring3 Converter interface. That shouldn't be a problem as spring3 is officially released and it's a high time to switch to the 'latest and the greatest';

  • the framework uses advanced generic type checker framework in order to filter config property change listeners by their target property types. The framework is decribed in details here. It's source code is packaged with the source code of 'config framework' as well;

0 comments:

Post a Comment