/*
* Copyright 2013 Martin Kouba
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.trimou.engine.resolver;
import static org.trimou.engine.priority.Priorities.after;
import static org.trimou.util.Checker.checkArgumentNotNull;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.trimou.engine.cache.ComputingCache;
import org.trimou.engine.config.ConfigurationKey;
import org.trimou.engine.config.SimpleConfigurationKey;
import org.trimou.exception.MustacheException;
import org.trimou.exception.MustacheProblem;
import org.trimou.util.Reflections;
/**
* Reflection-based resolver attempts to find a matching member on the context
* object class and its superclasses. Methods have higher priority than fields.
*
* @author Martin Kouba
* @see Reflections#findField(Class, String)
* @see Reflections#findMethod(Class, String)
*/
public class ReflectionResolver extends AbstractResolver {
public static final int REFLECTION_RESOLVER_PRIORITY = after(ListIndexResolver.LIST_RESOLVER_PRIORITY, 3);
public static final String COMPUTING_CACHE_CONSUMER_ID = ReflectionResolver.class
.getName();
/**
* Limit the size of the cache (e.g. to avoid problems when dynamic class
* compilation is involved). Use zero value to disable the cache.
*/
public static final ConfigurationKey MEMBER_CACHE_MAX_SIZE_KEY = new SimpleConfigurationKey(
ReflectionResolver.class.getName() + ".memberCacheMaxSize", 10000L);
/**
* Even if the runtime class of the context object changes try to apply the
* resolver.
*/
public static final ConfigurationKey HINT_FALLBACK_ENABLED_KEY = new SimpleConfigurationKey(
ReflectionResolver.class.getName() + ".hintFallbackEnabled", true);
private static final Logger LOGGER = LoggerFactory
.getLogger(ReflectionResolver.class);
private static final MemberWrapper ARRAY_GET_LENGTH = Array::getLength;
public static final String GET_PREFIX = "get";
public static final String IS_PREFIX = "is";
/**
* Lazy loading cache of lookup attempts (contains both hits and misses)
*/
private ComputingCache<MemberKey, Optional<MemberWrapper>> memberCache;
private boolean hintFallbackEnabled;
public ReflectionResolver() {
this(REFLECTION_RESOLVER_PRIORITY);
}
public ReflectionResolver(int priority) {
super(priority);
}
@Override
public Object resolve(Object contextObject, String name,
ResolutionContext context) {
if (contextObject == null) {
return null;
}
MemberWrapper wrapper;
MemberKey key = MemberKey.newInstance(contextObject, name);
if (memberCache != null) {
wrapper = memberCache.get(key).orElse(null);
} else {
wrapper = findWrapper(key).orElse(null);
}
if (wrapper == null) {
return null;
}
try {
return wrapper.getValue(contextObject);
} catch (Exception e) {
throw new MustacheException(
MustacheProblem.RENDER_REFLECT_INVOCATION_ERROR, e);
}
}
@Override
public Hint createHint(Object contextObject, String name,
ResolutionContext context) {
MemberKey key = MemberKey.newInstance(contextObject, name);
MemberWrapper wrapper;
if (memberCache != null) {
Optional<MemberWrapper> found = memberCache.getIfPresent(key);
wrapper = found != null ? found.get() : null;
} else {
wrapper = findWrapper(key).orElse(null);
}
if (wrapper != null) {
return new ReflectionHint(key, wrapper);
}
// This should never happen
return Hints.INAPPLICABLE_HINT;
}
@Override
public void init() {
long memberCacheMaxSize = configuration
.getLongPropertyValue(MEMBER_CACHE_MAX_SIZE_KEY);
LOGGER.debug("Initialized [memberCacheMaxSize: {}]",
memberCacheMaxSize);
if (memberCacheMaxSize > 0) {
memberCache = configuration.getComputingCacheFactory().create(
COMPUTING_CACHE_CONSUMER_ID, ReflectionResolver::findWrapper,
null, memberCacheMaxSize, null);
}
hintFallbackEnabled = configuration
.getBooleanPropertyValue(HINT_FALLBACK_ENABLED_KEY);
}
@Override
public Set<ConfigurationKey> getConfigurationKeys() {
return Collections.singleton(MEMBER_CACHE_MAX_SIZE_KEY);
}
/**
* The member cache may theoretically cause memory leaks due to using hard
* references to {@link Class} and {@link Member} instances. As a temporary
* workaround we provide a way to invalidate the cache or some of its
* entries (e.g. for a concrete classloader).
*
* @param predicate
* If null, all cache entries are discarded, otherwise an entry
* is only discarded if the given predicate returns
* <code>true</code> for the {@link MemberKey#getClass()}
*/
public void invalidateMemberCache(Predicate<Class<?>> predicate) {
if (memberCache == null) {
return;
}
if (predicate == null) {
memberCache.clear();
} else {
memberCache.invalidate((key) -> predicate.test(key.getClazz()));
}
}
long getMemberCacheSize() {
return memberCache != null ? memberCache.size() : 0L;
}
private static Optional<MemberWrapper> findWrapper(MemberKey key) {
// Get length of array objects
if (key.getClazz().isArray()) {
if (key.getName().equals("length")) {
return Optional.of(ARRAY_GET_LENGTH);
} else {
return Optional.empty();
}
}
// Find accesible method with the given name, no
// parameters and non-void return type
Method foundMethod = findMethod(key.getClazz(), key.getName());
if (foundMethod != null) {
if (!foundMethod.isAccessible()) {
SecurityActions.setAccessible(foundMethod);
}
return Optional.of(new MethodWrapper(foundMethod));
}
// Find public field
Field foundField = findField(key.getClazz(), key.getName());
if (foundField != null) {
if (!foundField.isAccessible()) {
SecurityActions.setAccessible(foundField);
}
return Optional.of(new FieldWrapper(foundField));
}
// Member not found
return Optional.empty();
}
private class ReflectionHint implements Hint {
private final MemberKey key;
private final MemberWrapper wrapper;
/**
*
* @param key
* @param wrapper
*/
ReflectionHint(MemberKey key, MemberWrapper wrapper) {
this.key = key;
this.wrapper = wrapper;
}
@Override
public Object resolve(Object contextObject, String name,
ResolutionContext context) {
if (contextObject == null) {
return null;
}
if (key.getClazz().equals(contextObject.getClass())) {
try {
return wrapper.getValue(contextObject);
} catch (Exception e) {
return null;
}
}
// The runtime class of the context object changed
if (ReflectionResolver.this.hintFallbackEnabled) {
return ReflectionResolver.this.resolve(contextObject, name,
context);
}
return null;
}
}
/**
* First tries to find a valid method with the same name, afterwards method
* following JavaBean naming convention (the method starts with
* <b>get/is</b> prefix).
*
* @param clazz
* @param name
* @return the found method or <code>null</code>
*/
static Method findMethod(Class<?> clazz, String name) {
checkArgumentNotNull(clazz);
checkArgumentNotNull(name);
Method foundMatch = null;
Method foundGetMatch = null;
Method foundIsMatch = null;
for (Method method : SecurityActions.getMethods(clazz)) {
if (!isMethodValid(method)) {
continue;
}
if (method.isBridge()) {
LOGGER.debug("Skipping bridge method {}", method);
continue;
}
if (name.equals(method.getName())) {
foundMatch = method;
} else if (Reflections.matchesPrefix(name, method.getName(),
GET_PREFIX)) {
foundGetMatch = method;
} else if (Reflections.matchesPrefix(name, method.getName(),
IS_PREFIX)) {
foundIsMatch = method;
}
}
if (foundMatch == null) {
foundMatch = (foundGetMatch != null ? foundGetMatch : foundIsMatch);
}
LOGGER.debug("{} method {}found [type: {}]", name, foundMatch != null ? "" : "not ", clazz.getName());
return foundMatch;
}
/**
* Tries to find a public field with the given name on the given class.
*
* @param clazz
* @param name
* @return the found field or <code>null</code>
*/
static Field findField(Class<?> clazz, String name) {
checkArgumentNotNull(clazz);
checkArgumentNotNull(name);
Field found = null;
for (Field field : SecurityActions.getFields(clazz)) {
if (field.getName().equals(name)) {
found = field;
}
}
LOGGER.debug("{} field {}found [type: {}]", name, found != null ? "" : "not ", clazz.getName());
return found;
}
/**
* A valid method:
* <ul>
* <li>is public</li>
* <li>has no parameters</li>
* <li>has non-void return type</li>
* <li>its declaring class is not {@link Object}</li>
* </ul>
*
* @param method
* @return <code>true</code> if the given method is considered a read method
*/
private static boolean isMethodValid(Method method) {
return method != null && Modifier.isPublic(method.getModifiers())
&& method.getParameterTypes().length == 0
&& !method.getReturnType().equals(Void.TYPE)
&& !Object.class.equals(method.getDeclaringClass());
}
}