ModelSharingControllerFactory.java
/**
* Copyright (c) 2017 European Organisation for Nuclear Research (CERN), All Rights Reserved.
*/
package org.minifx.fxmlloading.factories;
import static java.util.Objects.requireNonNull;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.function.Function;
import javax.inject.Inject;
import javax.inject.Named;
import org.minifx.fxmlloading.factories.impl.ControllerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A default factory for FXML controllers.
* <p>
* This class supports a basic field injection using {@link Inject @Inject} annotation. It can be used to initialize and
* configure controller, model or service instances, and bind them together.
* For every call of {@link #createController(Class)} method - a new controller instance is created, but all the
* injected dependencies (models, services, etc) are instantiated only once, stored in cache and used as singletons.
* </p>
* <p>
* The factory first makes an attempt to match field to be injected with System properties and then with an optional
* {@link #fromPropertiesProvider(Function) properties provider}. The matching is done using {@link Named @Named}
* annotation if present, otherwise using field's name.
* If no matching property is found, the factory consults dependencies cache trying find a match with the field by
* <b>exact</b> type. Otherwise it requests the instance provider to supply a new instance of given type, injects all
* its dependencies and puts into the cache.
* </p>
* <p>
* For primitive and {@link Enum enum} fields, the class makes a conversion when necessary e.g. when an integer field's
* value is injected from a System property (String).
* </p>
* Example usage:
*
* <pre>
* class PersonModel {
*
* {@literal @}Inject
* {@literal @}Named("person.age.visible")
* boolean defaultShowAge;
*
* {@literal @}Inject
* Side personDetailsPaneSide;
* }
*
* class PersonService {
* Person findByName(String name) {
* // ...
* }
* }
*
* class PersonController {
* {@literal @}Inject
* PersonModel model;
*
* {@literal @}Inject
* PersonService service;
*
* {@literal @}FXML
* void initialize() {
* // Initialize GUI with data from service, bind controls with model, etc.
* }
* }
* </pre>
* <p>
* The class is not synchronized. If multiple threads access it concurrently, it must be synchronized externally.
* </p>
*/
public class ModelSharingControllerFactory implements ControllerFactory {
private static final Logger LOGGER = LoggerFactory.getLogger(ModelSharingControllerFactory.class);
/**
* Calls {@link Class#newInstance()} without any further checks.
*/
private static final Function<Class<?>, Object> DEFAULT_INSTANCE_PROVIDER = clazz -> {
try {
return clazz.newInstance();
} catch (InstantiationException | IllegalAccessException ex) {
throw new IllegalStateException("Failed to instantiate " + clazz, ex);
}
};
private final Function<Class<?>, Object> instanceProvider;
private final Function<String, Object> propertiesProvider;
public ModelSharingControllerFactory(Function<Class<?>, Object> instanceProvider,
Function<String, Object> propertiesProvider) {
this.instanceProvider = requireNonNull(instanceProvider, "instanceProvider must not be null");
this.propertiesProvider = propertiesProvider;
}
public static ModelSharingControllerFactory newDefault() {
return new ModelSharingControllerFactory(DEFAULT_INSTANCE_PROVIDER, null);
}
public static ModelSharingControllerFactory fromPropertiesProvider(Function<String, Object> propertiesProvider) {
return new ModelSharingControllerFactory(DEFAULT_INSTANCE_PROVIDER, propertiesProvider);
}
private final Map<Class<?>, Object> dependencies = new WeakHashMap<>();
// For easier debugging
private int nestingLevel = 0;
/**
* Creates a new instance of the given controller class.
*
* @param <T> the type of the controller to create
* @param controllerClass class of the FXML controller used to locate the FXML file
* @return the instance of the controller
*/
public <T> T createController(Class<T> controllerClass) {
try {
return instanciateAndInjectDependencies(controllerClass);
} finally {
nestingLevel = 0;
}
}
<T> T instanciateAndInjectDependencies(Class<T> clazz) {
Objects.requireNonNull(clazz, "Controller class must not be null");
log("Creating instance of " + clazz + " using " + instanceProvider);
T controller = clazz.cast(instanceProvider.apply(clazz));
nestingLevel++;
injectDependencies(controller);
nestingLevel--;
return controller;
}
void injectDependencies(Object instance) {
for (Field field : findInjectableFields(instance.getClass())) {
injectFieldValue(field, instance);
}
}
void injectFieldValue(Field field, Object instance) {
log("Field to inject: " + field.getName() + " [" + field.getType().getName() + "]");
nestingLevel++;
Object fieldValue = getOrCreateFieldValue(field);
if (fieldValue != null) {
fieldValue = convertToFieldTypeIfNeeded(field, fieldValue);
log("Injecting into " + instance + ": " + field.getName() + "=" + fieldValue);
setField(field, instance, fieldValue);
}
nestingLevel--;
}
Object getOrCreateFieldValue(Field field) {
String propertyName = getPropertyName(field);
Object fieldValue = System.getProperty(propertyName);
if (fieldValue != null) {
log("Field value specified as system property [" + propertyName + "=" + fieldValue + "]");
return fieldValue;
}
if (propertiesProvider != null) {
fieldValue = propertiesProvider.apply(propertyName);
if (fieldValue != null) {
log("Field value [" + propertyName + "=" + fieldValue + "] provided by " + propertiesProvider);
return fieldValue;
}
}
Class<?> fieldType = field.getType();
fieldValue = dependencies.get(fieldType);
if (fieldValue != null) {
log("Found dependency value: " + fieldValue);
return fieldValue;
}
log("Dependency not found - trying to instantiate");
if (fieldType.isPrimitive() || fieldType.equals(String.class) || fieldType.isEnum()) {
log("Field type is primitive, String or Enum - skipping");
} else {
fieldValue = instanciateAndInjectDependencies(fieldType);
setDependency(fieldType, fieldValue);
}
return fieldValue;
}
static String getPropertyName(Field field) {
Named nameAnnotation = field.getAnnotation(Named.class);
if (nameAnnotation == null || "".equals(nameAnnotation.value())) {
return field.getName();
}
return nameAnnotation.value();
}
/**
* Stores given dependency in a cache.
*
* @param type class of the dependency used for matching with injectable fields
* @param dependency the instance to be injected into fields of specified type
* @throws NullPointerException if the {@code type} is {@code null}
*/
public void setDependency(Class<?> type, Object dependency) {
log("Cache dependency: " + type.getName() + " -> " + dependency);
dependencies.put(type, dependency);
}
/**
* Clears dependencies cache.
*/
public void clearDependencies() {
log("clearDependencies() called");
dependencies.clear();
}
/**
* Returns the currently used instance provider.
*
* @return instance provider
*/
public Function<Class<?>, Object> getInstanceProvider() {
return instanceProvider;
}
/**
* Returns currently used properties provider.
*
* @return properties provider
*/
public Function<String, Object> getPropertiesProvider() {
return propertiesProvider;
}
private void log(String msg) {
LOGGER.debug(nestingLevelIndentation() + msg);
}
private String nestingLevelIndentation() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < nestingLevel; i++) {
sb.append(" ");
}
return sb.toString();
}
// REFLECTION METHODS
private static Set<Field> findInjectableFields(Class<?> clazz) {
if (!injectAnnotationPresentOnClasspath()) {
return Collections.emptySet();
}
Set<Field> annotatedFields = new LinkedHashSet<>();
Class<?> classToSearch = clazz;
while (classToSearch != null && classToSearch != Object.class) {
Field[] fields = classToSearch.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Inject.class)) {
annotatedFields.add(field);
}
}
classToSearch = classToSearch.getSuperclass();
}
return annotatedFields;
}
/**
* The Inject annotation is optional. If client app uses it - it should add it to the classpath, otherwise we should
* not require its presence.
*/
private static boolean injectAnnotationPresentOnClasspath() {
try {
Class.forName("javax.inject.Inject");
return true;
} catch (ClassNotFoundException ex) {
LOGGER.debug("@Inject is not on the classpath", ex);
return false;
}
}
private static void setField(Field field, Object instance, Object fieldValue) {
try {
field.setAccessible(true);
field.set(instance, fieldValue);
} catch (IllegalArgumentException | IllegalAccessException ex) {
String fieldValueType = fieldValue == null ? null : fieldValue.getClass().getName();
throw new IllegalStateException("Failed to set " + fieldValue + " [" + fieldValueType + "] to " + field
+ " on instance " + instance, ex);
}
}
private static Object convertToFieldTypeIfNeeded(Field field, Object fieldValue) {
if (fieldValue == null) {
return fieldValue;
}
Class<?> fieldType = wrapIfPrimitive(field.getType());
Class<?> valueType = fieldValue.getClass();
if (fieldType.isAssignableFrom(valueType)) {
return fieldValue;
}
if (field.getType().isPrimitive() || field.getType().isEnum()) {
return valueOf(field, fieldType, fieldValue);
}
// fall back - will fail on set
return fieldValue;
}
private static Object valueOf(Field field, Class<?> fieldType, Object fieldValue) {
try {
Method valueOf = fieldType.getMethod("valueOf", String.class);
return valueOf.invoke(null, fieldValue.toString());
} catch (Exception ex) {
throw new IllegalStateException(
"Failed to convert " + fieldValue + " [" + fieldValue.getClass().getName() + "] to " + field, ex);
}
}
// Found nothing in JDK and don't want to pull Guava just for this
private static final Map<Class<?>, Class<?>> PRIMITIVES_TO_WRAPPERS = new HashMap<>();
static {
PRIMITIVES_TO_WRAPPERS.put(boolean.class, Boolean.class);
PRIMITIVES_TO_WRAPPERS.put(byte.class, Byte.class);
PRIMITIVES_TO_WRAPPERS.put(char.class, Character.class);
PRIMITIVES_TO_WRAPPERS.put(double.class, Double.class);
PRIMITIVES_TO_WRAPPERS.put(float.class, Float.class);
PRIMITIVES_TO_WRAPPERS.put(int.class, Integer.class);
PRIMITIVES_TO_WRAPPERS.put(long.class, Long.class);
PRIMITIVES_TO_WRAPPERS.put(short.class, Short.class);
}
static Class<?> wrapIfPrimitive(Class<?> type) {
if (type.isPrimitive()) {
return PRIMITIVES_TO_WRAPPERS.get(type);
}
return type;
}
@Override
public Object call(Class<?> controllerClass) {
return createController(controllerClass);
}
}