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.*;
40  
41  import info.magnolia.dynamic.MagnoliaProxy;
42  
43  import java.lang.reflect.Field;
44  import java.lang.reflect.InvocationTargetException;
45  import java.lang.reflect.Method;
46  import java.lang.reflect.Modifier;
47  import java.util.ArrayList;
48  import java.util.Collection;
49  import java.util.Collections;
50  import java.util.HashMap;
51  import java.util.HashSet;
52  import java.util.LinkedHashMap;
53  import java.util.LinkedHashSet;
54  import java.util.List;
55  import java.util.Map;
56  import java.util.Set;
57  import java.util.regex.Matcher;
58  import java.util.regex.Pattern;
59  
60  import org.apache.commons.lang3.StringUtils;
61  import org.objenesis.Objenesis;
62  import org.objenesis.ObjenesisStd;
63  import org.slf4j.Logger;
64  import org.slf4j.LoggerFactory;
65  
66  import com.google.common.base.Joiner;
67  import com.google.common.base.Objects;
68  
69  import net.bytebuddy.ByteBuddy;
70  import net.bytebuddy.implementation.FieldAccessor;
71  import net.bytebuddy.implementation.bind.annotation.AllArguments;
72  import net.bytebuddy.implementation.bind.annotation.Origin;
73  import net.bytebuddy.implementation.bind.annotation.RuntimeType;
74  import net.bytebuddy.implementation.bind.annotation.This;
75  
76  /**
77   * Helper for creating {@link info.magnolia.config.MutableWrapper} based on byte buddy library.
78   *
79   * @param <T> type of the object being wrapped
80   * @param <U> type of the resulting wrapper (must be extending {@code T})
81   */
82  class ByteBuddyMutableWrapperHelper<T, U extends T> {
83  
84      private static final String INVOKER = "_INVOKER";
85  
86      private static final Objenesis objenesis = new ObjenesisStd(true);
87  
88      U createWrapper(T source, Class<?> type, Set<Class<?>> interfaces) {
89          if (source == null) {
90              return null;
91          } else if (Modifier.isFinal(type.getModifiers())) {
92              return (U) source;
93          }
94  
95          BeanPropertyMethodInvoker invoker = new BeanPropertyMethodInvoker(source);
96  
97          Collections.addAll(interfaces, MagnoliaProxy.class, MutableWrapper.Mutable.class, InvokerResolver.class);
98          ArrayList<Class<?>> interfaceTypes = new ArrayList<>(interfaces);
99  
100         Class<?> clazz = new ByteBuddy()
101                 .subclass(type)
102                 .implement(interfaceTypes)
103                 .method(not(isDeclaredBy(Object.class).or(isEquals()).or(isToString())).and(isPublic())).intercept(to(new DefaultInterceptor(invoker)))
104                 .defineField(INVOKER, BeanPropertyMethodInvoker.class)
105                 .method(isDeclaredBy(InvokerResolver.class)).intercept(FieldAccessor.ofField(INVOKER))
106                 .method(isEquals()).intercept(to(new EqualsInterceptor()))
107                 .method(isToString()).intercept(to(new ToStringInterceptor()))
108                 .method(isHashCode()).intercept(to(new HashCodeInterceptor()))
109                 .make()
110                 .load(getClass().getClassLoader(), WRAPPER).getLoaded();
111 
112         InvokerResolver instance = (InvokerResolver) objenesis.newInstance(clazz);
113         instance.setInvoker(invoker);
114         return (U) instance;
115     }
116 
117     /**
118      * Mixin interface which makes it easier for the proxy to expose the object that it wraps.
119      */
120     public interface InvokerResolver {
121 
122         BeanPropertyMethodInvoker getInvoker();
123 
124         void setInvoker(BeanPropertyMethodInvoker invoker);
125     }
126 
127     /**
128      * Provides support for equality check. The strategy is as follows:
129      * - it is only possible that this proxy is equal to a proxy of the same kind
130      * - source objects are equal
131      * - all the values of modified properties are equal
132      */
133     public class EqualsInterceptor {
134 
135         @RuntimeType
136         public Object intercept(@This InvokerResolver that, @AllArguments Object[] args) {
137             if (args.length == 1 && args[0] instanceof InvokerResolver) {
138                 BeanPropertyMethodInvoker invoker = that.getInvoker();
139                 BeanPropertyMethodInvoker otherInvoker = ((InvokerResolver) args[0]).getInvoker();
140                 boolean sameTarget = otherInvoker.getTarget().equals(invoker.getTarget());
141                 if (sameTarget) {
142                     if (invoker.modifiedPropertyNames.equals(otherInvoker.modifiedPropertyNames)) {
143                         for (final String modifiedPropertyName : invoker.modifiedPropertyNames) {
144                             if (!Objects.equal(invoker.propertyValueCache.get(modifiedPropertyName), otherInvoker.propertyValueCache.get(modifiedPropertyName))) {
145                                 return false;
146                             }
147                         }
148                         return true;
149                     }
150                 }
151             }
152             return false;
153         }
154     }
155 
156     /**
157      * {@link Object#toString()} implementation which prints the wrapped object plus the modified properties.
158      */
159     public class ToStringInterceptor {
160 
161         @RuntimeType
162         public Object intercept(@This InvokerResolver that) {
163             final StringBuilder sb = new StringBuilder();
164             sb.append("Mutable wrapper of [")
165                     .append(that.getInvoker().getTarget())
166                     .append("]");
167 
168             if (!that.getInvoker().modifiedPropertyNames.isEmpty()) {
169                 sb.append(" with modified properties: ");
170                 List<String> modifiedPropertyStatements = new ArrayList<>(that.getInvoker().modifiedPropertyNames.size());
171                 for (final String modifiedPropertyName : that.getInvoker().modifiedPropertyNames) {
172                     modifiedPropertyStatements.add(String.format("{%s : %s}", modifiedPropertyName, that.getInvoker().propertyValueCache.get(modifiedPropertyName)));
173                 }
174                 sb.append(Joiner.on(", ").join(modifiedPropertyStatements));
175             }
176 
177             return sb.toString();
178         }
179     }
180 
181     /**
182      * Provides the hashcode value based on the wrapped object's hashcode plus combined hashcode of the modified properties' values.
183      */
184     public class HashCodeInterceptor {
185 
186         @RuntimeType
187         public Object intercept(@This InvokerResolver that) {
188             BeanPropertyMethodInvoker invoker = that.getInvoker();
189             int hashCode = invoker.getTarget().hashCode();
190             for (final String modifiedPropertyName : invoker.modifiedPropertyNames) {
191                 final Object modifiedValue = invoker.propertyValueCache.get(modifiedPropertyName);
192                 hashCode += modifiedValue == null ? 0 : modifiedValue.hashCode();
193             }
194             return hashCode;
195         }
196     }
197 
198     /**
199      * Catch-all interceptor which merely dispatches all the calls to {@link info.magnolia.config.ByteBuddyMutableWrapperHelper.BeanPropertyMethodInvoker}.
200      * Note that the annotated getters will be handled by other intercepters accordingly.
201      */
202     public class DefaultInterceptor {
203 
204         private final BeanPropertyMethodInvoker invoker;
205 
206         DefaultInterceptor(BeanPropertyMethodInvoker invoker) {
207             this.invoker = invoker;
208         }
209 
210         @RuntimeType
211         public Object intercept(@This InvokerResolver that, @Origin Method method, @AllArguments Object[] args) {
212             try {
213                 return invoker.invoke(that.getInvoker(), method, args);
214             } catch (Throwable e) {
215                 throw new RuntimeException(e);
216             }
217         }
218     }
219 
220     /**
221      * Invoker which is backing up the proxies produced by {@link MutableWrapper}. The following is provided:
222      * <ul>
223      * <li>Simple map based mutable state</li>
224      * <li>Support for getter invocations whose return values are delegated to the wrapped object, automatically cached in the state and wrapped with {@link MutableWrapper},
225      * collections and maps are copied.</li>
226      * <li>Support for setter invocations which are able to change the state.</li>
227      * <li>{@link info.magnolia.config.MutableWrapper.Mutable} interface support - a universal setter for interfaces that are immutable by default</li>
228      * </ul>
229      */
230     public static class BeanPropertyMethodInvoker {
231         private static final Logger log = LoggerFactory.getLogger(BeanPropertyMethodInvoker.class);
232         private static final Pattern getter = compile("^(?:get|is)(.+)$");
233         private static final Pattern setter = compile("^set(.+)$");
234 
235         final Set<String> modifiedPropertyNames = new HashSet<>();
236 
237         final Map<String, Object> propertyValueCache = new HashMap<>();
238 
239         private final Object target;
240 
241         private BeanPropertyMethodInvoker(Object proxyDelegate) {
242             target = proxyDelegate;
243         }
244 
245         public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
246             if (method.equals(MutableWrapper.Mutable.class.getMethod("setProperty", String.class, Object.class))) {
247                 invokeSetter(String.valueOf(args[0]), args[1]);
248                 return null;
249             }
250 
251             final Matcher setterInvocationMatcher = setter.matcher(method.getName());
252             if (setterInvocationMatcher.matches()) {
253                 if (args.length < 1) {
254                     log.debug("Encountered [{}] setter invocation without arguments, related type: [{}]", method.getName(), proxy.getClass().getName());
255                 } else {
256                     invokeSetter(StringUtils.uncapitalize(setterInvocationMatcher.group(1)), args[0]);
257                 }
258                 return null;
259             }
260 
261             final Matcher getterInvocationMatcher = getter.matcher(method.getName());
262             if (getterInvocationMatcher.matches()) {
263                 return invokeGetter(StringUtils.uncapitalize(getterInvocationMatcher.group(1)), method);
264             }
265 
266             final Map<String, Object> currentValues = applyFieldValues(this.propertyValueCache);
267             try {
268                 return method.invoke(getTarget(), args);
269             } finally {
270                 applyFieldValues(currentValues);
271             }
272         }
273 
274         private Map<String, Object> applyFieldValues(Map<String, Object> valuesToApply) {
275             final Map<String, Object> oldValues = new HashMap<>();
276             valuesToApply.forEach((propertyName, value) -> {
277                 try {
278                     final Field property = getTarget().getClass().getDeclaredField(propertyName);
279                     property.setAccessible(true);
280                     oldValues.put(propertyName, property.get(getTarget()));
281                     property.set(getTarget(), value);
282                 } catch (NoSuchFieldException | IllegalAccessException e) {
283                     log.debug("Reflective setting of the property: '{}' has failed.", propertyName, e);
284                 }
285             });
286 
287             return oldValues;
288         }
289 
290         Object getTarget() {
291             return target;
292         }
293 
294         private void invokeSetter(String propertyName, Object value) {
295             // track modified property
296             modifiedPropertyNames.add(propertyName);
297             // Remember the new explicit property value
298             propertyValueCache.put(propertyName, value);
299         }
300 
301         private Object invokeGetter(String propertyName, Method getterMethod) {
302             if (propertyValueCache.containsKey(propertyName)) {
303                 return propertyValueCache.get(propertyName);
304             }
305 
306             final Object fallbackValue;
307             try {
308                 fallbackValue = getterMethod.invoke(getTarget());
309             } catch (IllegalAccessException | InvocationTargetException e) {
310                 log.warn("Failed to invoke a fallback {} call due to a reflection operation problem: {}, returning null", getterMethod.getName(), e.getMessage(), e);
311                 return null;
312             }
313 
314             if (fallbackValue == null) {
315                 return null;
316             }
317 
318             Object wrappedValue;
319 
320             if (fallbackValue instanceof Collection) {
321                 wrappedValue = wrapCollection((Collection) fallbackValue);
322             } else if (fallbackValue instanceof Map) {
323                 wrappedValue = wrapMap((Map) fallbackValue);
324             } else {
325                 wrappedValue = MutableWrapper.wrap(fallbackValue);
326             }
327 
328             propertyValueCache.put(propertyName, wrappedValue);
329             return wrappedValue;
330         }
331 
332         private Map<?, ?> wrapMap(Map<?, ?> sourceMap) {
333             final Map<Object, Object> mapCopy = new LinkedHashMap<>();
334             for (final Map.Entry<?, ?> entry : sourceMap.entrySet()) {
335                 mapCopy.put(entry.getKey(), MutableWrapper.wrap(entry.getValue()));
336             }
337             return mapCopy;
338         }
339 
340         private Collection<?> wrapCollection(Collection<?> sourceCollection) {
341             final Collection<Object> collectionCopy = sourceCollection instanceof List ? new ArrayList<>() : new LinkedHashSet<>();
342             for (final Object element : sourceCollection) {
343                 collectionCopy.add(MutableWrapper.wrap(element));
344             }
345             return collectionCopy;
346         }
347     }
348 }