View Javadoc
1   /**
2    * This file Copyright (c) 2019 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.config;
35  
36  import static java.util.regex.Pattern.compile;
37  import static net.bytebuddy.dynamic.loading.ClassLoadingStrategy.Default.WRAPPER;
38  import static net.bytebuddy.implementation.MethodDelegation.to;
39  import static net.bytebuddy.matcher.ElementMatchers.not;
40  import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy;
41  import static net.bytebuddy.matcher.ElementMatchers.isPublic;
42  import static net.bytebuddy.matcher.ElementMatchers.isEquals;
43  import static net.bytebuddy.matcher.ElementMatchers.isToString;
44  import static net.bytebuddy.matcher.ElementMatchers.isHashCode;
45  
46  import info.magnolia.dynamic.MagnoliaProxy;
47  
48  import java.lang.reflect.Field;
49  import java.lang.reflect.InvocationTargetException;
50  import java.lang.reflect.Method;
51  import java.lang.reflect.Modifier;
52  import java.util.ArrayList;
53  import java.util.Collection;
54  import java.util.Collections;
55  import java.util.HashMap;
56  import java.util.HashSet;
57  import java.util.LinkedHashMap;
58  import java.util.LinkedHashSet;
59  import java.util.List;
60  import java.util.Map;
61  import java.util.Set;
62  import java.util.regex.Matcher;
63  import java.util.regex.Pattern;
64  
65  import net.bytebuddy.TypeCache;
66  import org.apache.commons.lang3.StringUtils;
67  import org.objenesis.Objenesis;
68  import org.objenesis.ObjenesisStd;
69  import org.slf4j.Logger;
70  import org.slf4j.LoggerFactory;
71  
72  import com.google.common.base.Joiner;
73  import com.google.common.base.Objects;
74  
75  import net.bytebuddy.implementation.bind.annotation.AllArguments;
76  import net.bytebuddy.implementation.bind.annotation.Origin;
77  import net.bytebuddy.implementation.bind.annotation.RuntimeType;
78  import net.bytebuddy.implementation.bind.annotation.This;
79  import net.bytebuddy.implementation.bind.annotation.FieldValue;
80  import net.bytebuddy.ByteBuddy;
81  import net.bytebuddy.implementation.FieldAccessor;
82  
83  /**
84   * Helper for creating {@link info.magnolia.config.MutableWrapper} based on byte buddy library.
85   *
86   * @param <T> type of the object being wrapped
87   * @param <U> type of the resulting wrapper (must be extending {@code T})
88   */
89  class ByteBuddyMutableWrapperHelper<T, U extends T> {
90  
91      private static final String INVOKER = "_INVOKER";
92  
93      private static final TypeCache<Class> typeCache = new TypeCache.WithInlineExpunction<>(TypeCache.Sort.SOFT);
94  
95      private static final Objenesis objenesis = new ObjenesisStd(true);
96  
97      U createWrapper(T source, Class<?> type, Set<Class<?>> interfaces) {
98          if (source == null) {
99              return null;
100         } else if (Modifier.isFinal(type.getModifiers())) {
101             return (U) source;
102         }
103 
104         BeanPropertyMethodInvoker invoker = new BeanPropertyMethodInvoker(source);
105 
106         // cache the generated proxies by the target type as long as it is not Object.class (e.g. can be if we're wrapping a
107         // mock object in tests), use the concrete instance's type otherwise.
108         Class cacheKey = Object.class.equals(type) ? source.getClass() : type;
109         Class clazz = typeCache.findOrInsert(getClass().getClassLoader(), cacheKey, () -> {
110 
111             Collections.addAll(interfaces, MagnoliaProxy.class, MutableWrapper.Mutable.class, InvokerResolver.class);
112             ArrayList<Class<?>> interfaceTypes = new ArrayList<>(interfaces);
113 
114             return new ByteBuddy()
115                     .subclass(type)
116                     .implement(interfaceTypes)
117                     .method(not(isDeclaredBy(Object.class).or(isEquals()).or(isToString()))
118                             .and(isPublic()))
119                     .intercept(to(new DefaultInterceptor()))
120                     .defineField(INVOKER, BeanPropertyMethodInvoker.class)
121                     .method(isDeclaredBy(InvokerResolver.class)).intercept(FieldAccessor.ofField(INVOKER))
122                     .method(isEquals()).intercept(to(new EqualsInterceptor()))
123                     .method(isToString()).intercept(to(new ToStringInterceptor()))
124                     .method(isHashCode()).intercept(to(new HashCodeInterceptor()))
125                     .make()
126                     .load(getClass().getClassLoader(), WRAPPER).getLoaded();
127         });
128 
129         InvokerResolver instance = (InvokerResolver) objenesis.newInstance(clazz);
130         instance.setInvoker(invoker);
131         return (U) instance;
132     }
133 
134     /**
135      * Mixin interface which makes it easier for the proxy to expose the object that it wraps.
136      */
137     public interface InvokerResolver {
138 
139         BeanPropertyMethodInvoker getInvoker();
140 
141         void setInvoker(BeanPropertyMethodInvoker invoker);
142     }
143 
144     /**
145      * Provides support for equality check. The strategy is as follows:
146      * - it is only possible that this proxy is equal to a proxy of the same kind
147      * - source objects are equal
148      * - all the values of modified properties are equal
149      */
150     public class EqualsInterceptor {
151 
152         @RuntimeType
153         public Object intercept(@This InvokerResolver that, @AllArguments Object[] args) {
154             if (args.length == 1 && args[0] instanceof InvokerResolver) {
155                 BeanPropertyMethodInvoker invoker = that.getInvoker();
156                 BeanPropertyMethodInvoker otherInvoker = ((InvokerResolver) args[0]).getInvoker();
157                 boolean sameTarget = otherInvoker.getTarget().equals(invoker.getTarget());
158                 if (sameTarget) {
159                     if (invoker.modifiedPropertyNames.equals(otherInvoker.modifiedPropertyNames)) {
160                         for (final String modifiedPropertyName : invoker.modifiedPropertyNames) {
161                             if (!Objects.equal(invoker.propertyValueCache.get(modifiedPropertyName), otherInvoker.propertyValueCache.get(modifiedPropertyName))) {
162                                 return false;
163                             }
164                         }
165                         return true;
166                     }
167                 }
168             }
169             return false;
170         }
171     }
172 
173     /**
174      * {@link Object#toString()} implementation which prints the wrapped object plus the modified properties.
175      */
176     public class ToStringInterceptor {
177 
178         @RuntimeType
179         public Object intercept(@This InvokerResolver that) {
180             final StringBuilder sb = new StringBuilder();
181             sb.append("Mutable wrapper of [")
182                     .append(that.getInvoker().getTarget())
183                     .append("]");
184 
185             if (!that.getInvoker().modifiedPropertyNames.isEmpty()) {
186                 sb.append(" with modified properties: ");
187                 List<String> modifiedPropertyStatements = new ArrayList<>(that.getInvoker().modifiedPropertyNames.size());
188                 for (final String modifiedPropertyName : that.getInvoker().modifiedPropertyNames) {
189                     modifiedPropertyStatements.add(String.format("{%s : %s}", modifiedPropertyName, that.getInvoker().propertyValueCache.get(modifiedPropertyName)));
190                 }
191                 sb.append(Joiner.on(", ").join(modifiedPropertyStatements));
192             }
193 
194             return sb.toString();
195         }
196     }
197 
198     /**
199      * Provides the hashcode value based on the wrapped object's hashcode plus combined hashcode of the modified properties' values.
200      */
201     public class HashCodeInterceptor {
202 
203         @RuntimeType
204         public Object intercept(@This InvokerResolver that) {
205             BeanPropertyMethodInvoker invoker = that.getInvoker();
206             int hashCode = invoker.getTarget().hashCode();
207             for (final String modifiedPropertyName : invoker.modifiedPropertyNames) {
208                 final Object modifiedValue = invoker.propertyValueCache.get(modifiedPropertyName);
209                 hashCode += modifiedValue == null ? 0 : modifiedValue.hashCode();
210             }
211             return hashCode;
212         }
213     }
214 
215     /**
216      * Catch-all interceptor which merely dispatches all the calls to {@link info.magnolia.config.ByteBuddyMutableWrapperHelper.BeanPropertyMethodInvoker}.
217      * Note that the annotated getters will be handled by other intercepters accordingly.
218      */
219     public class DefaultInterceptor {
220 
221         @RuntimeType
222         public Object intercept(@This InvokerResolver that, @Origin Method method, @AllArguments Object[] args, @FieldValue(INVOKER) BeanPropertyMethodInvoker invoker) {
223             try {
224                 return invoker.invoke(invoker, method, args);
225             } catch (Throwable e) {
226                 throw new RuntimeException(e);
227             }
228         }
229     }
230 
231     /**
232      * Invoker which is backing up the proxies produced by {@link MutableWrapper}. The following is provided:
233      * <ul>
234      * <li>Simple map based mutable state</li>
235      * <li>Support for getter invocations whose return values are delegated to the wrapped object, automatically cached in the state and wrapped with {@link MutableWrapper},
236      * collections and maps are copied.</li>
237      * <li>Support for setter invocations which are able to change the state.</li>
238      * <li>{@link info.magnolia.config.MutableWrapper.Mutable} interface support - a universal setter for interfaces that are immutable by default</li>
239      * </ul>
240      */
241     public static class BeanPropertyMethodInvoker {
242         private static final Logger log = LoggerFactory.getLogger(BeanPropertyMethodInvoker.class);
243         private static final Pattern getter = compile("^(?:get|is)(.+)$");
244         private static final Pattern setter = compile("^set(.+)$");
245 
246         final Set<String> modifiedPropertyNames = new HashSet<>();
247 
248         final Map<String, Object> propertyValueCache = new HashMap<>();
249 
250         private final Object target;
251 
252         private BeanPropertyMethodInvoker(Object proxyDelegate) {
253             target = proxyDelegate;
254         }
255 
256         public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
257             if (method.equals(MutableWrapper.Mutable.class.getMethod("setProperty", String.class, Object.class))) {
258                 invokeSetter(String.valueOf(args[0]), args[1]);
259                 return null;
260             }
261 
262             final Matcher setterInvocationMatcher = setter.matcher(method.getName());
263             if (setterInvocationMatcher.matches()) {
264                 if (args.length < 1) {
265                     log.debug("Encountered [{}] setter invocation without arguments, related type: [{}]", method.getName(), proxy.getClass().getName());
266                 } else {
267                     invokeSetter(StringUtils.uncapitalize(setterInvocationMatcher.group(1)), args[0]);
268                 }
269                 return null;
270             }
271 
272             final Matcher getterInvocationMatcher = getter.matcher(method.getName());
273             if (getterInvocationMatcher.matches() && args.length == 0) {
274                 return invokeGetter(StringUtils.uncapitalize(getterInvocationMatcher.group(1)), method);
275             }
276 
277             final Map<String, Object> currentValues = applyFieldValues(this.propertyValueCache);
278             try {
279                 return method.invoke(getTarget(), args);
280             } finally {
281                 applyFieldValues(currentValues);
282             }
283         }
284 
285         private Map<String, Object> applyFieldValues(Map<String, Object> valuesToApply) {
286             final Map<String, Object> oldValues = new HashMap<>();
287             valuesToApply.forEach((propertyName, value) -> {
288                 try {
289                     final Field property = getTarget().getClass().getDeclaredField(propertyName);
290                     property.setAccessible(true);
291                     oldValues.put(propertyName, property.get(getTarget()));
292                     property.set(getTarget(), value);
293                 } catch (NoSuchFieldException | IllegalAccessException e) {
294                     log.debug("Reflective setting of the property: '{}' has failed.", propertyName, e);
295                 }
296             });
297 
298             return oldValues;
299         }
300 
301         Object getTarget() {
302             return target;
303         }
304 
305         private void invokeSetter(String propertyName, Object value) {
306             // track modified property
307             modifiedPropertyNames.add(propertyName);
308             // Remember the new explicit property value
309             propertyValueCache.put(propertyName, value);
310         }
311 
312         private Object invokeGetter(String propertyName, Method getterMethod) {
313             if (propertyValueCache.containsKey(propertyName)) {
314                 return propertyValueCache.get(propertyName);
315             }
316 
317             final Object fallbackValue;
318             try {
319                 fallbackValue = getterMethod.invoke(getTarget());
320             } catch (IllegalAccessException | InvocationTargetException e) {
321                 log.warn("Failed to invoke a fallback {} call due to a reflection operation problem: {}, returning null", getterMethod.getName(), e.getMessage(), e);
322                 return null;
323             }
324 
325             if (fallbackValue == null) {
326                 return null;
327             }
328 
329             Object wrappedValue;
330 
331             if (fallbackValue instanceof Collection) {
332                 wrappedValue = wrapCollection((Collection) fallbackValue);
333             } else if (fallbackValue instanceof Map) {
334                 wrappedValue = wrapMap((Map) fallbackValue);
335             } else {
336                 wrappedValue = MutableWrapper.wrap(fallbackValue);
337             }
338 
339             propertyValueCache.put(propertyName, wrappedValue);
340             return wrappedValue;
341         }
342 
343         private Map<?, ?> wrapMap(Map<?, ?> sourceMap) {
344             final Map<Object, Object> mapCopy = new LinkedHashMap<>();
345             for (final Map.Entry<?, ?> entry : sourceMap.entrySet()) {
346                 mapCopy.put(entry.getKey(), MutableWrapper.wrap(entry.getValue()));
347             }
348             return mapCopy;
349         }
350 
351         private Collection<?> wrapCollection(Collection<?> sourceCollection) {
352             final Collection<Object> collectionCopy = sourceCollection instanceof List ? new ArrayList<>() : new LinkedHashSet<>();
353             for (final Object element : sourceCollection) {
354                 collectionCopy.add(MutableWrapper.wrap(element));
355             }
356             return collectionCopy;
357         }
358     }
359 }