diff --git a/README.md b/README.md index caf74ff..d347f08 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # Proxy A small yet powerful interception library that lets you manipulate existing objects and classes behavior runtime, -It's achieving this by using [javassist](http://jboss-javassist.github.io/javassist/) to do bytecode manipulation, +It's achieving this by using [javassist](http://jboss-javassist.github.io/javassist/) or [Byte Buddy](https://bytebuddy.net/) +to do bytecode manipulation (see [Bytecode engines](#bytecode-engines)), Proxy has a [fluent](http://en.wikipedia.org/wiki/Fluent_interface) interception API: ```java @@ -106,6 +107,44 @@ Proxy is a great building block for creating other innovative solutions. * It does bytecode manipulation with cglib similarly to how Proxy does the same with Javassist * It is missing features like: __recursive interception__ and __delegation__ and **object interception** +## Bytecode engines + +Proxy ships with two interchangeable bytecode manipulation engines. They implement the same behavior, so the engine is +purely an implementation detail: no code that uses `Proxy` has to change when you switch. + +| Engine | Name | Notes | +|---|---|---| +| [Javassist](http://jboss-javassist.github.io/javassist/) | `javassist` | The default, used by Proxy since its first release | +| [Byte Buddy](https://bytebuddy.net/) | `bytebuddy` | Alternative engine, useful if your application already depends on Byte Buddy or you hit a Javassist limitation | + +Select the engine for the whole JVM with a system property: + +```bash +java -Dproxy.engine=bytebuddy your.Application +``` + +Or at runtime, which affects every proxy created afterwards: + +```java +import com.ericsson.commonlibrary.proxy.ProxyConfiguration; +import com.ericsson.commonlibrary.proxy.ProxyConfiguration.Engine; + +ProxyConfiguration.setEngine(Engine.BYTEBUDDY); +ProxyConfiguration.reset(); // back to -Dproxy.engine, or javassist when that is unset +``` + +Or for a single call only, without affecting other threads: + +```java +SomeImpl obj = ProxyConfiguration.callWithEngine(Engine.BYTEBUDDY, + () -> Proxy.with(SomeImpl.class).interceptAll(i -> i.invoke()).get()); +``` + +Proxies from both engines can be mixed freely in the same JVM, and a proxy created by one engine can be proxied again by +the other. Already created proxies keep the engine they were created with. + +The whole test suite is executed twice by the build, once per engine, so both engines are verified to behave the same. + ## User Guide **Under Construction**: https://ericsson.github.io/proxy diff --git a/pom.xml b/pom.xml index af6093d..ebaea77 100644 --- a/pom.xml +++ b/pom.xml @@ -19,14 +19,15 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --> - + 4.0.0 com.ericsson.commonlibrary proxy 1.2.29-SNAPSHOT jar Proxy - A small yet powerful interception library that lets you manipulate existing objects and classes behavior runtime, It's achieving this by using javassist to do bytecode manipulation + A small yet powerful interception library that lets you manipulate existing objects and classes behavior runtime, It's achieving this by using javassist or Byte Buddy to do bytecode manipulation ${scm.repo.url.web} Ericsson @@ -51,6 +52,7 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 2.25.3 3.4 3.30.2-GA + 1.18.12 3.1.4 @@ -58,6 +60,8 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 3.5.0 3.2.0 3.5.4 + + false 3.14.1 3.6.2 false @@ -88,6 +92,11 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. javassist ${javassist.version} + + net.bytebuddy + byte-buddy + ${bytebuddy.version} + org.slf4j slf4j-api @@ -175,6 +184,35 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. false + + + + default-test + + test + + + + javassist + + + + + + bytebuddy-test + test + + test + + + ${skip.bytebuddy.tests} + + bytebuddy + + ${project.build.directory}/surefire-reports-bytebuddy + + + org.apache.maven.plugins @@ -186,8 +224,8 @@ SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - [3.8.1,4.0.0) - + [3.8.1,4.0.0) + true ${maven.enforcer.plugin.disabled} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyInterceptorDispatcher.java b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyInterceptorDispatcher.java new file mode 100644 index 0000000..f11de07 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyInterceptorDispatcher.java @@ -0,0 +1,93 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayDeque; + +/** + * Holds the interceptor stack of one byte-buddy generated proxy instance. This is the byte-buddy counterpart of + * javassist's {@code MethodHandler}. + */ +final class ByteBuddyInterceptorDispatcher { + + private final ArrayDeque interceptorStack = new ArrayDeque<>(); + + /** + * @param dispatcher + * the dispatcher field value of the proxy, {@code null} while a super constructor is still running + * @param self + * the proxy instance + * @param method + * the method that was called + * @param superMethod + * accessor for the real implementation, {@code null} when there is none + * @param arguments + * the arguments of the call + * + * @return whatever the interceptor chain or the real implementation returns + * + * @throws Throwable + * anything the interceptor chain or the real implementation throws + */ + static Object dispatch(Object dispatcher, Object self, Method method, Method superMethod, Object[] arguments) + throws Throwable { + Method accessibleSuperMethod = makeAccessible(superMethod); + if (dispatcher == null) { + // A super constructor is calling an overridable method before the proxy is fully wired up. Javassist + // behaves the same way and simply runs the real implementation. + return invokeSuper(self, accessibleSuperMethod, method, arguments); + } + return ((ByteBuddyInterceptorDispatcher) dispatcher).dispatch(self, method, accessibleSuperMethod, arguments); + } + + private Object dispatch(Object self, Method method, Method superMethod, Object[] arguments) throws Throwable { + Object handled = InterceptableProxyInvoker.handleInterceptableProxyMethod(interceptorStack, method, arguments); + if (handled != InterceptableProxyInvoker.NOT_HANDLED) { + return handled; + } + return new Invocation(self, method, superMethod, arguments, interceptorStack.clone()).invoke(); + } + + private static Object invokeSuper(Object self, Method superMethod, Method method, Object[] arguments) + throws Throwable { + if (superMethod == null) { + throw new UnsupportedOperationException( + "There exist's no implementation of method: " + method.getName() + "(...) to delegate to."); + } + try { + return superMethod.invoke(self, arguments); + } catch (InvocationTargetException e) { + Util.filterExceptionAndRethrowCorrect(e); + throw ProxyException.returnThisShouldNeverHappen("Failed to re-throw real exception: ", e); + } + } + + @SuppressWarnings({ "deprecation", "squid:S3011" }) + private static Method makeAccessible(Method superMethod) { + if (superMethod != null && !superMethod.isAccessible()) { + superMethod.setAccessible(true); + } + return superMethod; + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyDispatcher.java b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyDispatcher.java new file mode 100644 index 0000000..4165243 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyDispatcher.java @@ -0,0 +1,76 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.lang.reflect.Method; + +import net.bytebuddy.implementation.bind.annotation.AllArguments; +import net.bytebuddy.implementation.bind.annotation.FieldValue; +import net.bytebuddy.implementation.bind.annotation.Origin; +import net.bytebuddy.implementation.bind.annotation.RuntimeType; +import net.bytebuddy.implementation.bind.annotation.SuperMethod; +import net.bytebuddy.implementation.bind.annotation.This; + +/** + * Every method of a byte-buddy generated proxy is compiled into a call to {@link #dispatch}. The class therefore has to + * be public, because a generated proxy class is placed in the package of the type it proxies, which is normally not the + * package of this library. + *

+ * This class is an implementation detail and is not part of the supported API. Never call it directly. + */ +public final class ByteBuddyProxyDispatcher { + + /** + * Name of the synthetic field that holds the interceptor state of a single proxy instance. + */ + public static final String DISPATCHER_FIELD = "proxy$interceptorDispatcher"; + + private ByteBuddyProxyDispatcher() { + // hidden + } + + /** + * Called by every method of a byte-buddy generated proxy. + * + * @param self + * the proxy instance + * @param dispatcher + * value of the {@value #DISPATCHER_FIELD} field, {@code null} while a super constructor is still running + * @param method + * the method that was called + * @param superMethod + * an accessor for the real implementation, {@code null} when there is none + * @param arguments + * the arguments of the call + * + * @return whatever the interceptor chain or the real implementation returns + * + * @throws Throwable + * anything the interceptor chain or the real implementation throws + */ + @RuntimeType + public static Object dispatch(@This Object self, @FieldValue(DISPATCHER_FIELD) Object dispatcher, + @Origin Method method, @SuperMethod(nullIfImpossible = true) Method superMethod, + @AllArguments Object[] arguments) throws Throwable { + return ByteBuddyInterceptorDispatcher.dispatch(dispatcher, self, method, superMethod, arguments); + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyEngineFactory.java b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyEngineFactory.java new file mode 100644 index 0000000..8f41473 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ByteBuddyProxyEngineFactory.java @@ -0,0 +1,401 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +import org.objenesis.ObjenesisHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.ericsson.commonlibrary.proxy.InterceptableProxyFactory.JavaBeanSetter; + +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.TypeCache; +import net.bytebuddy.description.method.MethodDescription; +import net.bytebuddy.description.modifier.SyntheticState; +import net.bytebuddy.description.modifier.Visibility; +import net.bytebuddy.dynamic.DynamicType; +import net.bytebuddy.dynamic.loading.ClassInjector; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import net.bytebuddy.dynamic.scaffold.subclass.ConstructorStrategy; +import net.bytebuddy.implementation.Implementation; +import net.bytebuddy.implementation.MethodDelegation; +import net.bytebuddy.matcher.ElementMatcher; +import net.bytebuddy.matcher.ElementMatchers; + +/** + * {@link ProxyEngineFactory} implemented with Byte Buddy. + *

+ * Every intercepted method is delegated to {@link ByteBuddyProxyDispatcher#dispatch}, which receives the real + * implementation as a {@code @SuperMethod} accessor. That accessor is what makes {@link Invocation#invoke()} able to + * proceed into the original code, exactly like javassist's {@code proceed} method does. + */ +final class ByteBuddyProxyEngineFactory implements ProxyEngineFactory { + + private static final Logger LOG = LoggerFactory.getLogger(ByteBuddyProxyEngineFactory.class); + + private static final String PROXY_CLASS_INFIX = "$ProxyByByteBuddy$"; + private static final String FALLBACK_PACKAGE = "com.ericsson.commonlibrary.proxy.generated"; + + private static final AtomicLong COUNTER = new AtomicLong(); + + /** + * All virtual methods that can be overridden, minus the {@link Object} methods that are not + * {@code toString/hashCode/equals}. + */ + private static final ElementMatcher.Junction INTERCEPTED_METHODS = ElementMatchers + .not(ElementMatchers.isStatic()).and(ElementMatchers.not(ElementMatchers.isFinal())) + .and(ElementMatchers.not(ElementMatchers.isPrivate())) + .and(ElementMatchers.not(ElementMatchers.isDeclaredBy(Object.class)).or(ElementMatchers.isToString()) + .or(ElementMatchers.isHashCode()).or(ElementMatchers.isEquals())); + + private static final Implementation DELEGATION = MethodDelegation.withDefaultConfiguration() + .to(ByteBuddyProxyDispatcher.class); + + /** + * Generated proxy classes are shared between proxy instances of the same shape. Soft references let the JVM drop + * them again under metaspace pressure. + */ + private static final TypeCache TYPE_CACHE = new TypeCache.WithInlineExpunction<>(TypeCache.Sort.SOFT); + + @Override + public String getEngineName() { + return ProxyConfiguration.Engine.BYTEBUDDY.getName(); + } + + @Override + public T createInterfaceProxy(Class... interfaces) { + Class[] accessible = InterceptableProxyFactory.filterOnlyAccessableInterfaces(interfaces[0], interfaces); + Class proxyClass = generate(null, accessible, null, ConstructorStrategy.Default.IMITATE_SUPER_CLASS_OPENING); + return attach(newInstanceWithDefaultConstructor(proxyClass)); + } + + @Override + public T createInterfaceJavaBeanProxy(Class toJavaBeanify) { + Class[] accessible = InterceptableProxyFactory.filterOnlyAccessableInterfaces(toJavaBeanify, toJavaBeanify); + Class proxyClass = generate(null, accessible, + InterceptableProxyFactory.findMissingJavaBeanSetters(toJavaBeanify), + ConstructorStrategy.Default.IMITATE_SUPER_CLASS_OPENING); + return attach(newInstanceWithDefaultConstructor(proxyClass)); + } + + @Override + public T createClassProxy(Class classToIntercept, Class... interfaces) { + Class superClass = resolveSuperClass(classToIntercept); + Class[] accessible = InterceptableProxyFactory.filterOnlyAccessableInterfaces(classToIntercept, interfaces); + Class proxyClass = generate(superClass, accessible, null, + ConstructorStrategy.Default.IMITATE_SUPER_CLASS_OPENING); + return attach(newInstanceWithDefaultConstructorOrObjenesis(proxyClass)); + } + + @Override + public T createClassProxyWithArguments(Class classToIntercept, Object... constructorArgs) { + Class superClass = resolveSuperClass(classToIntercept); + Class proxyClass = generate(superClass, new Class[0], null, + ConstructorStrategy.Default.IMITATE_SUPER_CLASS_OPENING); + Class[] parameterTypes = InterceptableProxyFactory.findConstructorParameterTypes(superClass, + constructorArgs); + try { + return attach(proxyClass.getDeclaredConstructor(parameterTypes).newInstance(constructorArgs)); + } catch (ReflectiveOperationException e) { + throw new ProxyException("Not able to create proxy", e); + } + } + + @Override + public T createClassJavaBeanProxy(Class toJavaBeanify, Class... interfaces) { + Class superClass = resolveSuperClass(toJavaBeanify); + Class[] accessible = InterceptableProxyFactory.filterOnlyAccessableInterfaces(toJavaBeanify, interfaces); + Class proxyClass = generate(superClass, accessible, + InterceptableProxyFactory.findMissingJavaBeanSetters(toJavaBeanify), + ConstructorStrategy.Default.IMITATE_SUPER_CLASS_OPENING); + return attach(newInstanceWithDefaultConstructorOrObjenesis(proxyClass)); + } + + @Override + public T createObjectProxyIfNeeded(T objectToIntercept, Class... interfaces) { + if (!Util.isNewProxyNeeded(objectToIntercept, InterceptableProxyFactory.makeAValidInterfaceArray(interfaces))) { + return objectToIntercept; + } + Class superClass = resolveSuperClass(objectToIntercept.getClass()); + Class[] accessible = InterceptableProxyFactory.filterOnlyAccessableInterfaces(objectToIntercept.getClass(), + interfaces); + // No constructor of the proxied type may run, the object it delegates to already exists. + Class proxyClass = generate(superClass, accessible, null, ConstructorStrategy.Default.NO_CONSTRUCTORS); + T proxy = attach(ObjenesisHelper.newInstance(proxyClass)); + Proxy.getProxyInterface(proxy).addInterceptor(new InterceptorDelegator(objectToIntercept)); + return proxy; + } + + /** + * Proxying a proxy would clash with the dispatcher field of the inner proxy, so the inner proxy is unwrapped first. + * Javassist does the same thing for its own generated classes. + */ + private static Class resolveSuperClass(Class candidate) { + Class superClass = candidate; + while (superClass != null && superClass != Object.class && isGeneratedProxyClass(superClass)) { + superClass = superClass.getSuperclass(); + } + return superClass == null ? Object.class : superClass; + } + + private static boolean isGeneratedProxyClass(Class candidate) { + if (!InterceptableProxy.class.isAssignableFrom(candidate)) { + return false; + } + for (Field field : candidate.getDeclaredFields()) { + if (ByteBuddyProxyDispatcher.DISPATCHER_FIELD.equals(field.getName())) { + return true; + } + } + // A javassist proxy class, which can not be extended either because of its duplicated handler field. + return candidate.getName().contains("_$$_"); + } + + private Class generate(Class superClass, Class[] interfaces, List extraSetters, + ConstructorStrategy constructorStrategy) { + + Class[] validInterfaces = InterceptableProxyFactory.makeAValidInterfaceArray(interfaces); + Class effectiveSuperClass = superClass == null ? Object.class : superClass; + Class host = resolveHost(effectiveSuperClass, validInterfaces); + Class lookupHost = host != null ? host : ByteBuddyProxyDispatcher.class; + + // Proxy classes are shared, all instance state lives in the dispatcher field, so proxying the same type twice + // must not generate a second class. Javassist caches its proxy classes the same way. + CacheKey key = new CacheKey(effectiveSuperClass, validInterfaces, extraSetters, constructorStrategy); + try { + return TYPE_CACHE.findOrInsert(lookupHost.getClassLoader(), key, () -> generateNewClass(effectiveSuperClass, + validInterfaces, extraSetters, constructorStrategy, host), TYPE_CACHE); + } catch (RuntimeException e) { + // The type cache wraps whatever the generation threw, so the original ProxyException is dug out again. + throw asProxyException(e); + } + } + + private static ProxyException asProxyException(RuntimeException e) { + for (Throwable candidate = e; candidate != null; candidate = candidate.getCause()) { + if (candidate instanceof ProxyException) { + return (ProxyException) candidate; + } + } + return new ProxyException("Not able to create proxy", e); + } + + private static Class generateNewClass(Class superClass, Class[] interfaces, + List extraSetters, ConstructorStrategy constructorStrategy, Class host) { + + // The generated class is named after the type being proxied, because a proxy's default toString() is expected + // to reveal what it is a proxy of. + Class nameSource = superClass != Object.class ? superClass : interfaces[0]; + + try { + DynamicType.Builder builder = new ByteBuddy().subclass(superClass, constructorStrategy) + .name(nextProxyClassName(host, nameSource)).implement(interfaces) + // Private, because a public field would make Util consider the proxy unsafe to intercept further. + .defineField(ByteBuddyProxyDispatcher.DISPATCHER_FIELD, Object.class, Visibility.PRIVATE, + SyntheticState.SYNTHETIC); + + if (extraSetters != null) { + for (JavaBeanSetter setter : extraSetters) { + builder = builder.defineMethod(setter.getName(), void.class, Visibility.PUBLIC) + .withParameter(setter.getValueType()).intercept(DELEGATION); + } + } + + DynamicType.Unloaded unloaded = builder.method(INTERCEPTED_METHODS).intercept(DELEGATION).make(); + return load(unloaded, host); + } catch (ProxyException e) { + throw e; + } catch (RuntimeException | LinkageError e) { + throw new ProxyException("Not able to create proxy", e); + } + } + + /** + * Identifies a generated proxy class. Only type names are kept, never {@link Class} references, so that a cached + * class never keeps a class loader alive. + */ + private static final class CacheKey { + + private final String superClassName; + private final List interfaceNames; + private final String discriminator; + + CacheKey(Class superClass, Class[] interfaces, List extraSetters, + ConstructorStrategy constructorStrategy) { + this.superClassName = superClass.getName(); + this.interfaceNames = new ArrayList<>(interfaces.length); + for (Class anInterface : interfaces) { + interfaceNames.add(anInterface.getName()); + } + StringBuilder builder = new StringBuilder(constructorStrategy.toString()); + if (extraSetters != null) { + for (JavaBeanSetter setter : extraSetters) { + builder.append('|').append(setter.getName()).append('(').append(setter.getValueType().getName()) + .append(')'); + } + } + this.discriminator = builder.toString(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CacheKey)) { + return false; + } + CacheKey that = (CacheKey) other; + return superClassName.equals(that.superClassName) && interfaceNames.equals(that.interfaceNames) + && discriminator.equals(that.discriminator); + } + + @Override + public int hashCode() { + return Objects.hash(superClassName, interfaceNames, discriminator); + } + } + + /** + * Decides which type's package the generated proxy class is placed in. It has to be the package of the proxied type + * whenever anything about that type is package private, otherwise the generated class would not be allowed to + * implement its interfaces or override its methods. + */ + private static Class resolveHost(Class superClass, Class[] interfaces) { + if (isDefinableIn(superClass)) { + return superClass; + } + for (Class candidate : interfaces) { + if (!Modifier.isPublic(candidate.getModifiers()) && isDefinableIn(candidate)) { + return candidate; + } + } + for (Class candidate : interfaces) { + if (isDefinableIn(candidate)) { + return candidate; + } + } + return null; + } + + /** + * A class can host a generated proxy when it was loaded by a normal class loader and its package is not owned by + * the JDK. + */ + private static boolean isDefinableIn(Class candidate) { + if (candidate == Object.class || candidate.getClassLoader() == null || candidate.isArray()) { + return false; + } + String packageName = candidate.getPackageName(); + return !packageName.isEmpty() && !packageName.startsWith("java.") && !packageName.startsWith("javax.") + && !packageName.startsWith("jdk.") && !packageName.startsWith("sun.") + && !packageName.startsWith("com.sun."); + } + + private static String nextProxyClassName(Class host, Class superClass) { + String packageName = host != null ? host.getPackageName() : FALLBACK_PACKAGE; + return packageName + "." + simpleNameOf(superClass) + PROXY_CLASS_INFIX + COUNTER.incrementAndGet(); + } + + /** + * A java identifier derived from the super class, only used to make generated class names readable. Anonymous and + * local classes have names that can not be reused as is. + */ + private static String simpleNameOf(Class superClass) { + if (superClass == Object.class) { + return "Proxy"; + } + String name = superClass.getName(); + int lastDot = name.lastIndexOf('.'); + StringBuilder simpleName = new StringBuilder(); + for (int i = lastDot + 1; i < name.length(); i++) { + char character = name.charAt(i); + simpleName.append(Character.isJavaIdentifierPart(character) && character != '$' ? character : '_'); + } + return simpleName.length() == 0 ? "Proxy" : simpleName.toString(); + } + + private static Class load(DynamicType.Unloaded unloaded, Class host) { + Class lookupHost = host != null ? host : ByteBuddyProxyDispatcher.class; + ClassLoader classLoader = lookupHost.getClassLoader(); + return unloaded.load(classLoader, strategyFor(lookupHost, unloaded)).getLoaded(); + } + + /** + * Defining the proxy in the same runtime package as the type it proxies is what makes package private types, + * interfaces and methods work. A method handle lookup is the only way to do that without any JVM flags, so it is + * tried first and class injection is only used as a fallback. + */ + private static ClassLoadingStrategy strategyFor(Class host, DynamicType.Unloaded unloaded) { + try { + return ClassLoadingStrategy.UsingLookup.of(MethodHandles.privateLookupIn(host, MethodHandles.lookup())); + } catch (IllegalAccessException | RuntimeException e) { + LOG.debug( + "Was not able to define {} next to {} with a method handle lookup. Falls back to class injection.", + unloaded.getTypeDescription().getName(), host.getName(), e); + } + if (ClassInjector.UsingReflection.isAvailable()) { + return ClassLoadingStrategy.Default.INJECTION; + } + return ClassLoadingStrategy.Default.WRAPPER; + } + + private static T newInstanceWithDefaultConstructor(Class proxyClass) { + try { + return (T) proxyClass.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new ProxyException("Not able to create proxy", e); + } + } + + private static T newInstanceWithDefaultConstructorOrObjenesis(Class proxyClass) { + try { + return (T) proxyClass.getDeclaredConstructor().newInstance(); + } catch (NoSuchMethodException e) { + LOG.debug("{} has no usable empty constructor. Will construct it without calling a constructor.", + proxyClass.getSuperclass().getName()); + return (T) ObjenesisHelper.newInstance(proxyClass); + } catch (ReflectiveOperationException e) { + throw new ProxyException("Not able to create proxy", e); + } + } + + @SuppressWarnings("squid:S3011") + private static T attach(Object proxy) { + try { + Field field = proxy.getClass().getDeclaredField(ByteBuddyProxyDispatcher.DISPATCHER_FIELD); + field.setAccessible(true); + field.set(proxy, new ByteBuddyInterceptorDispatcher()); + return (T) proxy; + } catch (ReflectiveOperationException e) { + throw new ProxyException("Not able to create proxy", e); + } + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyFactory.java b/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyFactory.java index 0cdda55..dad1057 100644 --- a/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyFactory.java +++ b/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyFactory.java @@ -22,236 +22,135 @@ of this software and associated documentation files (the "Software"), to deal package com.ericsson.commonlibrary.proxy; import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import org.objenesis.ObjenesisHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javassist.CannotCompileException; -import javassist.ClassPool; -import javassist.CtClass; -import javassist.CtMethod; -import javassist.CtNewMethod; -import javassist.NotFoundException; -import javassist.util.proxy.MethodFilter; -import javassist.util.proxy.MethodHandler; -import javassist.util.proxy.ProxyFactory; -import javassist.util.proxy.ProxyObject; - /** + * Entry point that the rest of the library uses to create proxies. All work is delegated to the + * {@link ProxyEngineFactory} that {@link ProxyConfiguration} currently selects. + *

+ * The helpers in this class are shared between all engines so that they behave identically no matter which engine is + * used. + * * @author Elis Edlund (elis.edlund@ericsson.com) */ final class InterceptableProxyFactory { - private static final String ADDITIONAL_METHODS_SUFFIX = "ExtendedByProxy"; - - private static final Logger LOG = LoggerFactory.getLogger(InterceptableProxyFactory.class); - - private enum ProxyType { - OBJECT, INTERFACE, CLASS - } - - private static final MethodFilter METHOD_FILTER = new MethodFilter() { - - @Override - public boolean isHandled(final Method method) { - // if not overridable Object.class method - if (method.getDeclaringClass().equals(Object.class) && !Util.isToStringOrHashcodeOrEqualsMethod(method)) { - return false; - } - return true; - } - }; - - private final ProxyFactory factory = new ProxyFactory(); - private final ProxyType type; - private Object[] constructorArgs = null; + /** + * Interfaces that must never be added to a generated proxy, because they are engine internals of an already + * existing proxy that is being proxied again. + */ + private static final String JAVASSIST_PROXY_OBJECT = "javassist.util.proxy.ProxyObject"; + private static final String JAVASSIST_PROXY = "javassist.util.proxy.Proxy"; - InterceptableProxyFactory(ProxyType type) { - this.type = type; - factory.setFilter(METHOD_FILTER); + private InterceptableProxyFactory() { + // hidden } - T build() { - try { - return createProxyObject(type); - } catch (Exception e) { - throw new ProxyException("Not able to create proxy", e); - } - } - - void setInterfaces(Class... interfaces) { - factory.setInterfaces(makeAValidInterfaceArray(interfaces)); - } - - void setSuperclass(Class classToIntercept) { - if (ProxyObject.class.isAssignableFrom(classToIntercept)) { // Because it's not possible to proxy a proxy class - // because of setHandler duplicate exception. - factory.setSuperclass(classToIntercept.getSuperclass()); - } else { - factory.setSuperclass(classToIntercept); - } + static T createANewInterfaceProxy(Class... interfaces) { + return ProxyEngineProvider.getCurrentFactory().createInterfaceProxy(interfaces); } - private static Class addAdditionalSetMethodsToClass(Class javaBean) { - - CtClass cc = null; - try { - return Thread.currentThread().getContextClassLoader() - .loadClass(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX); - } catch (ClassNotFoundException e) { // NOSONAR - LOG.trace(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX + " did not exist. Creates one"); - cc = createNewClass(javaBean); - addAdditionalSetMethodsTo(cc); - } catch (RuntimeException e) { // NOSONAR (workaround) Powermock's classloader throws RuntimeException. - if (e.getCause() != null && e.getCause() instanceof NotFoundException) { - LOG.trace(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX + " did not exist. Creates one"); - cc = createNewClass(javaBean); - addAdditionalSetMethodsTo(cc); - } - } - try { - double javaSpecVersion = Double.parseDouble(System.getProperty("java.specification.version")); - if (javaSpecVersion > 10) { // Use different API on Java11 and later versions. - return cc.toClass(javaBean); - } else { - return cc.toClass(Thread.currentThread().getContextClassLoader(), javaBean.getProtectionDomain()); - } - } catch (CannotCompileException e) { - LOG.warn( - "Was not able to create new proxy class. Will use the provided one instead which won't have the additional methods.", - e); - } - return javaBean; + static T createANewInterfaceJavaBeanProxy(Class toJavaBeanify) { + return ProxyEngineProvider.getCurrentFactory().createInterfaceJavaBeanProxy(toJavaBeanify); } - private static CtClass createNewClass(Class javaBean) { - - final ClassPool pool = ClassPool.getDefault(); - - CtClass cc = null; - try { - cc = pool.get(getJavaBeanClassName(javaBean)); - cc.setName(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX); - } catch (final NotFoundException e) { - ProxyException.throwThisShouldNeverHappen(e); - } - try { - cc.setSuperclass(pool.get(getJavaBeanClassName(javaBean))); - } catch (CannotCompileException e) { - throw new ProxyException("Was not able to create new proxy class", e); - } catch (NotFoundException e) { - ProxyException.throwThisShouldNeverHappen(e); - } - return cc; + static T createANewClassJavaBeanProxy(Class toJavaBeanify, Class... interfaces) { + return ProxyEngineProvider.getCurrentFactory().createClassJavaBeanProxy(toJavaBeanify, interfaces); } - private static String getJavaBeanClassName(Class javaBean) { - String className = javaBean.getCanonicalName(); - if (javaBean.getEnclosingClass() != null) { - className = javaBean.getEnclosingClass().getCanonicalName() + "$" + javaBean.getSimpleName(); - } - return className; + static T createANewObjectProxyIfNeeded(final T objectToIntercept, Class... interfaces) { + return ProxyEngineProvider.getCurrentFactory().createObjectProxyIfNeeded(objectToIntercept, interfaces); } - private static void addAdditionalSetMethodsTo(CtClass classToAddMethodTo) { - for (CtMethod method : classToAddMethodTo.getMethods()) { - String methodName = method.getName(); - if ((methodName.startsWith("get") || methodName.startsWith("is")) - && !"getClass".contentEquals(methodName)) { - String newMethodName = methodName.replaceFirst("get|is", "set"); - CtMethod newMethod = null; - try { - CtClass[] param = new CtClass[] { method.getReturnType() }; - newMethod = CtNewMethod.abstractMethod(ClassPool.getDefault().get(Void.class.getCanonicalName()), - newMethodName, param, null, classToAddMethodTo); - } catch (NotFoundException e) { - ProxyException.throwThisShouldNeverHappen(e); - } - try { - classToAddMethodTo.addMethod(newMethod); - } catch (CannotCompileException e) { - LOG.debug("Was not able to add a new method to proxy class, method: " + newMethodName); - LOG.trace("Reason why it was not able to add method:", e); - } - } - } + static T createANewClassProxy(final Class classToIntercept, Class... interfaces) { + return ProxyEngineProvider.getCurrentFactory().createClassProxy(classToIntercept, interfaces); } - private static Class[] filterOnlyAccessableInterfaces(final Class classToIntercept, Class... interfaces) { + static T createANewClassProxyWithArguments(final Class classToIntercept, Object... constructorArgs) { + return ProxyEngineProvider.getCurrentFactory().createClassProxyWithArguments(classToIntercept, constructorArgs); + } + + /** + * Drops the interfaces that the generated proxy class would not be allowed to implement, which are the ones that + * are neither public/protected nor declared in the same package as the type being proxied. + * + * @param classToIntercept + * the type being proxied, decides which package is "the same package" + * @param interfaces + * the candidate interfaces + * + * @return the accessible subset of the given interfaces + */ + static Class[] filterOnlyAccessableInterfaces(final Class classToIntercept, Class... interfaces) { List> newClassList = new ArrayList>(); for (Class inter : interfaces) { int modifiers = inter.getModifiers(); boolean accessable = Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers); - if (accessable || classToIntercept.getPackage().equals(inter.getPackage())) { + if (accessable || samePackage(classToIntercept, inter)) { newClassList.add(inter); } } return newClassList.toArray(new Class[newClassList.size()]); } - private static Class[] makeAValidInterfaceArray(Class... interfaces) { + private static boolean samePackage(Class one, Class other) { + if (one.getPackage() == null) { + return other.getPackage() == null; + } + return one.getPackage().equals(other.getPackage()); + } + + /** + * Removes duplicates, removes engine internal interfaces of proxies that are being proxied again, and makes sure + * that {@link InterceptableProxy} is present and last. + * + * @param interfaces + * the candidate interfaces + * + * @return an ordered array of interfaces that a proxy can be generated for + */ + static Class[] makeAValidInterfaceArray(Class... interfaces) { // makes a ordered set of the interfaces. (removes duplicates) Set> set = new LinkedHashSet>(Arrays.asList(interfaces)); - set.remove(InterceptableProxy.class); // remove is exists + set.remove(InterceptableProxy.class); // remove if it exists + set.removeIf(InterceptableProxyFactory::isEngineInternalInterface); set.add(InterceptableProxy.class); // needed by this library. - set.remove(ProxyObject.class); // not allowed by javassit. - set.remove(ProxyObject.class); // not allowed by javassit. return set.toArray(new Class[set.size()]); } - @SuppressWarnings("unchecked") - private T createProxyObject(ProxyType type) - throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { - - if (type == ProxyType.OBJECT) { - try { - return createProxyWithObjenesis(); // will not call a constructor. - } catch (Throwable t) { // NOSONAR - return createProxyObject(ProxyType.CLASS); - } - } else if (type == ProxyType.INTERFACE) { - return (T) factory.create(null, null, new JavassistInterceptorMethodHandler()); - } else { // ProxyType.CLASS - - // DONE 1 use constructor arguments. - // DONE 2 use default constructor - // DONE 3 use objenesis - if (constructorArgs != null) { - return (T) factory.create(findConstructorParameterTypes(factory.getSuperclass(), constructorArgs), - constructorArgs, new JavassistInterceptorMethodHandler()); - } - - try { - // Create object with methodHandler, used the empty constructor. - return (T) factory.create(null, null, new JavassistInterceptorMethodHandler()); - } catch (NoSuchMethodException e) { - LOG.debug( - "Was not able to create proxy with constructor or it does not exist. Will try to construct without constructor"); - return createProxyWithObjenesis(); - } - } + private static boolean isEngineInternalInterface(Class candidate) { + String name = candidate.getName(); + return JAVASSIST_PROXY_OBJECT.equals(name) || JAVASSIST_PROXY.equals(name); } - private Class[] findConstructorParameterTypes(Class clazz, Object... args) { - + /** + * Finds the parameter types of the constructor that matches the given arguments. + * + * @param clazz + * the class that declares the constructor + * @param args + * the arguments that the constructor should accept + * + * @return the parameter types of the first matching constructor + */ + static Class[] findConstructorParameterTypes(Class clazz, Object... args) { for (Constructor constructor : clazz.getDeclaredConstructors()) { Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length != args.length) { + continue; + } boolean matchingArgs = false; for (int i = 0; i < parameterTypes.length; i++) { if (args[i] == null && !parameterTypes[i].isPrimitive()) { matchingArgs = true; // null should match all Object types - } else if (parameterTypes[i].isAssignableFrom(args[i].getClass())) { + } else if (args[i] != null && parameterTypes[i].isAssignableFrom(args[i].getClass())) { matchingArgs = true; } else { matchingArgs = false; @@ -266,91 +165,87 @@ private Class[] findConstructorParameterTypes(Class clazz, Object... args) "Did not find any constructor matching the provided arguments: " + Arrays.asList(args)); } - private T createProxyWithObjenesis() { - Object obj = ObjenesisHelper.newInstance(factory.createClass()); - ((ProxyObject) obj).setHandler(new JavassistInterceptorMethodHandler()); - return (T) obj; - } - - static T createANewInterfaceProxy(Class... interfaces) { - InterceptableProxyFactory builder = new InterceptableProxyFactory(ProxyType.INTERFACE); - builder.setInterfaces(filterOnlyAccessableInterfaces(interfaces[0], interfaces)); - return builder.build(); - } - - static T createANewInterfaceJavaBeanProxy(Class toJavaBeanify) { - return createANewInterfaceProxy(addAdditionalSetMethodsToClass(toJavaBeanify)); - } - - static T createANewClassJavaBeanProxy(Class toJavaBeanify, Class... interfaces) { - Class modifiedClass = addAdditionalSetMethodsToClass(toJavaBeanify); - return (T) createANewClassProxy(modifiedClass, interfaces); + /** + * The {@link Object} methods that a proxy is allowed to intercept. All the other ones are final and can not be + * overridden anyway. + * + * @param method + * the method to check + * + * @return true when the method should be intercepted + */ + static boolean isInterceptableMethod(Method method) { + return !method.getDeclaringClass().equals(Object.class) || Util.isToStringOrHashcodeOrEqualsMethod(method); + } + + /** + * Collects the setter methods that a java bean proxy is missing, so that an engine can add them. + * + * @param beanType + * the interface or abstract class that is turned into a java bean + * + * @return one entry per setter that has to be generated + */ + static List findMissingJavaBeanSetters(Class beanType) { + Set existing = new LinkedHashSet<>(); + for (Method method : beanType.getMethods()) { + existing.add(signatureKey(method.getName(), method.getParameterTypes())); + } + List missing = new ArrayList<>(); + for (Method method : beanType.getMethods()) { + if (method.getParameterCount() != 0 || method.getReturnType() == void.class + || "getClass".equals(method.getName()) || Modifier.isStatic(method.getModifiers())) { + continue; + } + String setterName = toSetterName(method.getName()); + if (setterName == null) { + continue; + } + Class valueType = method.getReturnType(); + if (existing.add(signatureKey(setterName, new Class[] { valueType }))) { + missing.add(new JavaBeanSetter(setterName, valueType)); + } + } + return missing; } - static T createANewObjectProxyIfNeeded(final T objectToIntercept, Class... interfaces) { - if (!Util.isNewProxyNeeded(objectToIntercept, makeAValidInterfaceArray(interfaces))) { - return objectToIntercept; + private static String toSetterName(String getterName) { + if (getterName.startsWith("get") && getterName.length() > 3) { + return "set" + getterName.substring(3); } - - // was not a already proxy object -> create new one. - InterceptableProxyFactory builder = new InterceptableProxyFactory(ProxyType.OBJECT); - - builder.setSuperclass(objectToIntercept.getClass()); - builder.setInterfaces(filterOnlyAccessableInterfaces(objectToIntercept.getClass(), interfaces)); - T proxy = builder.build(); - Proxy.getProxyInterface(proxy).addInterceptor(new InterceptorDelegator(objectToIntercept)); - return proxy; + if (getterName.startsWith("is") && getterName.length() > 2) { + return "set" + getterName.substring(2); + } + return null; } - static T createANewClassProxy(final Class classToIntercept, Class... interfaces) { - InterceptableProxyFactory builder = new InterceptableProxyFactory(ProxyType.CLASS); - builder.setSuperclass(classToIntercept); - builder.setInterfaces(filterOnlyAccessableInterfaces(classToIntercept, interfaces)); - return builder.build(); + private static String signatureKey(String name, Class[] parameterTypes) { + StringBuilder key = new StringBuilder(name); + for (Class parameterType : parameterTypes) { + key.append('|').append(parameterType.getName()); + } + return key.toString(); } - static T createANewClassProxyWithArguments(final Class classToIntercept, Object... constructorArgs) { - InterceptableProxyFactory builder = new InterceptableProxyFactory(ProxyType.CLASS); - builder.constructorArgs = constructorArgs; - builder.setSuperclass(classToIntercept); - builder.setInterfaces(new Class[0]); - return builder.build(); - } + /** + * A setter method that has to be generated for a java bean proxy. + */ + static final class JavaBeanSetter { - private static class JavassistInterceptorMethodHandler implements MethodHandler { + private final String name; + private final Class valueType; - private static Method addInterceptorMethod; - private static Method removeInterceptorMethod; - private static Method getInterceptorListMethod; - static { - try { - addInterceptorMethod = InterceptableProxy.class.getMethod("addInterceptor", Interceptor.class); - removeInterceptorMethod = InterceptableProxy.class.getMethod("removeInterceptor", Interceptor.class); - getInterceptorListMethod = InterceptableProxy.class.getMethod("getInterceptorList"); - } catch (SecurityException | NoSuchMethodException e) { - ProxyException.throwThisShouldNeverHappen(e); - } + JavaBeanSetter(String name, Class valueType) { + this.name = name; + this.valueType = valueType; } - private final ArrayDeque interceptorStack = new ArrayDeque<>(); - - @Override - public Object invoke(Object self, Method method, Method proceed, Object[] args) throws Throwable { - - if (Util.methodSignatureEquals(method, addInterceptorMethod)) { - interceptorStack.push((Interceptor) args[0]); - return null; - } - if (Util.methodSignatureEquals(method, removeInterceptorMethod)) { - interceptorStack.remove(args[0]); - return null; - } - if (Util.methodSignatureEquals(method, getInterceptorListMethod)) { - return interceptorStack; - } + String getName() { + return name; + } - return new Invocation(self, method, proceed, args, interceptorStack.clone()).invoke(); - // TODO wrap the checked exception if it is thrown even if its not declared in the interface. + Class getValueType() { + return valueType; } } } diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyInvoker.java b/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyInvoker.java new file mode 100644 index 0000000..314c1ac --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/InterceptableProxyInvoker.java @@ -0,0 +1,71 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.lang.reflect.Method; +import java.util.Deque; + +/** + * Implements the three {@link InterceptableProxy} methods on behalf of every engine, so that they behave identically no + * matter which engine generated the proxy. + */ +final class InterceptableProxyInvoker { + + /** + * Returned when the intercepted method is not one of the {@link InterceptableProxy} methods. A dedicated sentinel + * is needed because {@code null} is a valid return value. + */ + static final Object NOT_HANDLED = new Object(); + + private static Method addInterceptorMethod; + private static Method removeInterceptorMethod; + private static Method getInterceptorListMethod; + + static { + try { + addInterceptorMethod = InterceptableProxy.class.getMethod("addInterceptor", Interceptor.class); + removeInterceptorMethod = InterceptableProxy.class.getMethod("removeInterceptor", Interceptor.class); + getInterceptorListMethod = InterceptableProxy.class.getMethod("getInterceptorList"); + } catch (SecurityException | NoSuchMethodException e) { + ProxyException.throwThisShouldNeverHappen(e); + } + } + + private InterceptableProxyInvoker() { + // hidden + } + + static Object handleInterceptableProxyMethod(Deque interceptorStack, Method method, Object[] args) { + if (Util.methodSignatureEquals(method, addInterceptorMethod)) { + interceptorStack.push((Interceptor) args[0]); + return null; + } + if (Util.methodSignatureEquals(method, removeInterceptorMethod)) { + interceptorStack.remove(args[0]); + return null; + } + if (Util.methodSignatureEquals(method, getInterceptorListMethod)) { + return interceptorStack; + } + return NOT_HANDLED; + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/Invocation.java b/src/main/java/com/ericsson/commonlibrary/proxy/Invocation.java index b25b46c..8161a64 100644 --- a/src/main/java/com/ericsson/commonlibrary/proxy/Invocation.java +++ b/src/main/java/com/ericsson/commonlibrary/proxy/Invocation.java @@ -119,7 +119,7 @@ public Object getParameter2() { public Object invoke() throws Throwable { if (interceptorStack.isEmpty()) { - if (Util.isMethodWithImplementation(method)) { + if (proceed != null && Util.isMethodWithImplementation(method)) { try { return proceed.invoke(target, parameters); // invoke original } catch (InvocationTargetException e) { diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/JavassistProxyEngineFactory.java b/src/main/java/com/ericsson/commonlibrary/proxy/JavassistProxyEngineFactory.java new file mode 100644 index 0000000..2e02f93 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/JavassistProxyEngineFactory.java @@ -0,0 +1,296 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayDeque; + +import org.objenesis.ObjenesisHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javassist.CannotCompileException; +import javassist.ClassPool; +import javassist.CtClass; +import javassist.CtMethod; +import javassist.CtNewMethod; +import javassist.NotFoundException; +import javassist.util.proxy.MethodFilter; +import javassist.util.proxy.MethodHandler; +import javassist.util.proxy.ProxyFactory; +import javassist.util.proxy.ProxyObject; + +/** + * The original {@link ProxyEngineFactory}, implemented with + * javassist. + * + * @author Elis Edlund (elis.edlund@ericsson.com) + */ +final class JavassistProxyEngineFactory implements ProxyEngineFactory { + + private static final String ADDITIONAL_METHODS_SUFFIX = "ExtendedByProxy"; + + private static final Logger LOG = LoggerFactory.getLogger(JavassistProxyEngineFactory.class); + + private enum ProxyType { + OBJECT, INTERFACE, CLASS + } + + private static final MethodFilter METHOD_FILTER = InterceptableProxyFactory::isInterceptableMethod; + + @Override + public String getEngineName() { + return ProxyConfiguration.Engine.JAVASSIST.getName(); + } + + @Override + public T createInterfaceProxy(Class... interfaces) { + Builder builder = new Builder(ProxyType.INTERFACE); + builder.setInterfaces(InterceptableProxyFactory.filterOnlyAccessableInterfaces(interfaces[0], interfaces)); + return builder.build(); + } + + @Override + public T createInterfaceJavaBeanProxy(Class toJavaBeanify) { + return createInterfaceProxy(addAdditionalSetMethodsToClass(toJavaBeanify)); + } + + @Override + public T createClassJavaBeanProxy(Class toJavaBeanify, Class... interfaces) { + Class modifiedClass = addAdditionalSetMethodsToClass(toJavaBeanify); + return (T) createClassProxy(modifiedClass, interfaces); + } + + @Override + public T createObjectProxyIfNeeded(final T objectToIntercept, Class... interfaces) { + if (!Util.isNewProxyNeeded(objectToIntercept, InterceptableProxyFactory.makeAValidInterfaceArray(interfaces))) { + return objectToIntercept; + } + + // was not a already proxy object -> create new one. + Builder builder = new Builder(ProxyType.OBJECT); + + builder.setSuperclass(objectToIntercept.getClass()); + builder.setInterfaces( + InterceptableProxyFactory.filterOnlyAccessableInterfaces(objectToIntercept.getClass(), interfaces)); + T proxy = builder.build(); + Proxy.getProxyInterface(proxy).addInterceptor(new InterceptorDelegator(objectToIntercept)); + return proxy; + } + + @Override + public T createClassProxy(final Class classToIntercept, Class... interfaces) { + Builder builder = new Builder(ProxyType.CLASS); + builder.setSuperclass(classToIntercept); + builder.setInterfaces(InterceptableProxyFactory.filterOnlyAccessableInterfaces(classToIntercept, interfaces)); + return builder.build(); + } + + @Override + public T createClassProxyWithArguments(final Class classToIntercept, Object... constructorArgs) { + Builder builder = new Builder(ProxyType.CLASS); + builder.constructorArgs = constructorArgs; + builder.setSuperclass(classToIntercept); + builder.setInterfaces(new Class[0]); + return builder.build(); + } + + private static Class addAdditionalSetMethodsToClass(Class javaBean) { + + CtClass cc = null; + try { + return Thread.currentThread().getContextClassLoader() + .loadClass(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX); + } catch (ClassNotFoundException e) { // NOSONAR + LOG.trace(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX + " did not exist. Creates one"); + cc = createNewClass(javaBean); + addAdditionalSetMethodsTo(cc); + } catch (RuntimeException e) { // NOSONAR (workaround) Powermock's classloader throws RuntimeException. + if (e.getCause() != null && e.getCause() instanceof NotFoundException) { + LOG.trace(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX + " did not exist. Creates one"); + cc = createNewClass(javaBean); + addAdditionalSetMethodsTo(cc); + } + } + try { + double javaSpecVersion = Double.parseDouble(System.getProperty("java.specification.version")); + if (javaSpecVersion > 10) { // Use different API on Java11 and later versions. + return cc.toClass(javaBean); + } else { + return cc.toClass(Thread.currentThread().getContextClassLoader(), javaBean.getProtectionDomain()); + } + } catch (CannotCompileException e) { + LOG.warn( + "Was not able to create new proxy class. Will use the provided one instead which won't have the additional methods.", + e); + } + return javaBean; + } + + private static CtClass createNewClass(Class javaBean) { + + final ClassPool pool = ClassPool.getDefault(); + + CtClass cc = null; + try { + cc = pool.get(getJavaBeanClassName(javaBean)); + cc.setName(javaBean.getCanonicalName() + ADDITIONAL_METHODS_SUFFIX); + } catch (final NotFoundException e) { + ProxyException.throwThisShouldNeverHappen(e); + } + try { + cc.setSuperclass(pool.get(getJavaBeanClassName(javaBean))); + } catch (CannotCompileException e) { + throw new ProxyException("Was not able to create new proxy class", e); + } catch (NotFoundException e) { + ProxyException.throwThisShouldNeverHappen(e); + } + return cc; + } + + private static String getJavaBeanClassName(Class javaBean) { + String className = javaBean.getCanonicalName(); + if (javaBean.getEnclosingClass() != null) { + className = javaBean.getEnclosingClass().getCanonicalName() + "$" + javaBean.getSimpleName(); + } + return className; + } + + private static void addAdditionalSetMethodsTo(CtClass classToAddMethodTo) { + for (CtMethod method : classToAddMethodTo.getMethods()) { + String methodName = method.getName(); + if ((methodName.startsWith("get") || methodName.startsWith("is")) + && !"getClass".contentEquals(methodName)) { + String newMethodName = methodName.replaceFirst("get|is", "set"); + CtMethod newMethod = null; + try { + CtClass[] param = new CtClass[] { method.getReturnType() }; + newMethod = CtNewMethod.abstractMethod(ClassPool.getDefault().get(Void.class.getCanonicalName()), + newMethodName, param, null, classToAddMethodTo); + } catch (NotFoundException e) { + ProxyException.throwThisShouldNeverHappen(e); + } + try { + classToAddMethodTo.addMethod(newMethod); + } catch (CannotCompileException e) { + LOG.debug("Was not able to add a new method to proxy class, method: " + newMethodName); + LOG.trace("Reason why it was not able to add method:", e); + } + } + } + } + + /** + * Configures and creates a single javassist proxy. + */ + private static final class Builder { + + private final ProxyFactory factory = new ProxyFactory(); + private final ProxyType type; + private Object[] constructorArgs = null; + + Builder(ProxyType type) { + this.type = type; + factory.setFilter(METHOD_FILTER); + } + + T build() { + try { + return createProxyObject(type); + } catch (Exception e) { + throw new ProxyException("Not able to create proxy", e); + } + } + + void setInterfaces(Class... interfaces) { + factory.setInterfaces(InterceptableProxyFactory.makeAValidInterfaceArray(interfaces)); + } + + void setSuperclass(Class classToIntercept) { + if (ProxyObject.class.isAssignableFrom(classToIntercept)) { // Because it's not possible to proxy a proxy + // class because of setHandler duplicate + // exception. + factory.setSuperclass(classToIntercept.getSuperclass()); + } else { + factory.setSuperclass(classToIntercept); + } + } + + @SuppressWarnings("unchecked") + private T createProxyObject(ProxyType proxyType) throws InstantiationException, IllegalAccessException, + InvocationTargetException, NoSuchMethodException { + + if (proxyType == ProxyType.OBJECT) { + try { + return createProxyWithObjenesis(); // will not call a constructor. + } catch (Throwable t) { // NOSONAR + return createProxyObject(ProxyType.CLASS); + } + } else if (proxyType == ProxyType.INTERFACE) { + return (T) factory.create(null, null, new JavassistInterceptorMethodHandler()); + } else { // ProxyType.CLASS + + // DONE 1 use constructor arguments. + // DONE 2 use default constructor + // DONE 3 use objenesis + if (constructorArgs != null) { + return (T) factory.create(InterceptableProxyFactory + .findConstructorParameterTypes(factory.getSuperclass(), constructorArgs), constructorArgs, + new JavassistInterceptorMethodHandler()); + } + + try { + // Create object with methodHandler, used the empty constructor. + return (T) factory.create(null, null, new JavassistInterceptorMethodHandler()); + } catch (NoSuchMethodException e) { + LOG.debug( + "Was not able to create proxy with constructor or it does not exist. Will try to construct without constructor"); + return createProxyWithObjenesis(); + } + } + } + + private T createProxyWithObjenesis() { + Object obj = ObjenesisHelper.newInstance(factory.createClass()); + ((ProxyObject) obj).setHandler(new JavassistInterceptorMethodHandler()); + return (T) obj; + } + } + + private static class JavassistInterceptorMethodHandler implements MethodHandler { + + private final ArrayDeque interceptorStack = new ArrayDeque<>(); + + @Override + public Object invoke(Object self, Method method, Method proceed, Object[] args) throws Throwable { + + Object handled = InterceptableProxyInvoker.handleInterceptableProxyMethod(interceptorStack, method, args); + if (handled != InterceptableProxyInvoker.NOT_HANDLED) { + return handled; + } + + return new Invocation(self, method, proceed, args, interceptorStack.clone()).invoke(); + // TODO wrap the checked exception if it is thrown even if its not declared in the interface. + } + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ProxyConfiguration.java b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyConfiguration.java new file mode 100644 index 0000000..0c359fc --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyConfiguration.java @@ -0,0 +1,206 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.util.concurrent.Callable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Selects which bytecode manipulation engine the proxy library uses. Two engines are supported and both implement the + * exact same behavior, so switching engine never requires any change of the code that uses {@link Proxy}. + *

+ * The engine can be selected in three ways, in falling order of precedence: + *

    + *
  1. Per thread, with {@link #setThreadEngine(Engine)} or {@link #callWithEngine(Engine, Callable)}.
  2. + *
  3. Globally at runtime, with {@link #setEngine(Engine)}.
  4. + *
  5. At JVM startup, with the system property {@code -Dproxy.engine=javassist|bytebuddy}.
  6. + *
+ * When nothing is configured, {@link Engine#JAVASSIST} is used. + *

+ * Already created proxies are unaffected by a later engine change, only proxies created afterwards use the new engine. + * Proxies from both engines can safely be mixed in the same JVM, and a proxy from one engine can be proxied again by + * the other one. + */ +public final class ProxyConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(ProxyConfiguration.class); + + /** + * Name of the system property that selects the engine at JVM startup. + */ + public static final String ENGINE_PROPERTY = "proxy.engine"; + + /** + * The bytecode manipulation engines that the proxy library supports. + */ + public enum Engine { + + /** + * Javassist based engine. This is the default and the + * engine the library has been using since its first release. + */ + JAVASSIST("javassist"), + + /** + * Byte Buddy based engine. + */ + BYTEBUDDY("bytebuddy"); + + private final String name; + + Engine(String name) { + this.name = name; + } + + /** + * @return the lower case name of the engine, as used by the {@value ProxyConfiguration#ENGINE_PROPERTY} system + * property + */ + public String getName() { + return name; + } + + /** + * Resolves an {@link Engine} from its name, ignoring case and surrounding whitespace. + * + * @param name + * the engine name, {@code null} resolves to the default engine + * + * @return the matching engine, or the default engine when the name is unknown + */ + public static Engine fromName(String name) { + if (name == null || name.trim().isEmpty()) { + return DEFAULT_ENGINE; + } + for (Engine engine : values()) { + if (engine.name.equalsIgnoreCase(name.trim())) { + return engine; + } + } + LOG.warn("Unknown proxy engine '{}', falling back to '{}'", name, DEFAULT_ENGINE.getName()); + return DEFAULT_ENGINE; + } + } + + private static final Engine DEFAULT_ENGINE = Engine.JAVASSIST; + + private static volatile Engine globalEngine = Engine.fromName(System.getProperty(ENGINE_PROPERTY)); + + private static final ThreadLocal THREAD_ENGINE = new ThreadLocal<>(); + + private ProxyConfiguration() { + // hidden + } + + /** + * @return the engine that will be used for the next proxy created by this thread. + */ + public static Engine getEngine() { + Engine threadEngine = THREAD_ENGINE.get(); + return threadEngine != null ? threadEngine : globalEngine; + } + + /** + * Sets the engine used by all threads that do not have a thread local engine of their own. + * + * @param engine + * the engine to use, never {@code null} + */ + public static void setEngine(Engine engine) { + if (engine == null) { + throw new IllegalArgumentException("engine can not be null"); + } + globalEngine = engine; + LOG.debug("Global proxy engine changed to: {}", engine.getName()); + } + + /** + * Sets the engine used by all threads that do not have a thread local engine of their own. + * + * @param engineName + * name of the engine to use, see {@link Engine#fromName(String)} + */ + public static void setEngine(String engineName) { + setEngine(Engine.fromName(engineName)); + } + + /** + * Sets the engine for the current thread only. Overrides {@link #setEngine(Engine)} for this thread until + * {@link #clearThreadEngine()} is called. + * + * @param engine + * the engine to use, never {@code null} + */ + public static void setThreadEngine(Engine engine) { + if (engine == null) { + throw new IllegalArgumentException("engine can not be null"); + } + THREAD_ENGINE.set(engine); + } + + /** + * Removes the thread local engine of the current thread, so that the global engine is used again. + */ + public static void clearThreadEngine() { + THREAD_ENGINE.remove(); + } + + /** + * Runs the given code with a specific engine on the current thread and restores the previous setting afterwards. + * + * @param + * the type returned by the callable + * @param engine + * the engine used while the callable runs + * @param callable + * the code to run + * + * @return whatever the callable returns + * + * @throws Exception + * anything the callable throws + */ + public static T callWithEngine(Engine engine, Callable callable) throws Exception { + Engine previous = THREAD_ENGINE.get(); + setThreadEngine(engine); + try { + return callable.call(); + } finally { + if (previous == null) { + THREAD_ENGINE.remove(); + } else { + THREAD_ENGINE.set(previous); + } + } + } + + /** + * Resets the configuration back to the {@value #ENGINE_PROPERTY} system property, or to the default engine when + * that property is not set. Also removes any thread local engine of the current thread. + */ + public static void reset() { + THREAD_ENGINE.remove(); + setEngine(Engine.fromName(System.getProperty(ENGINE_PROPERTY))); + } +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineFactory.java b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineFactory.java new file mode 100644 index 0000000..1f4b8d1 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineFactory.java @@ -0,0 +1,129 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +/** + * Strategy interface for the bytecode manipulation engine that backs the proxy library. + *

+ * Every implementation has to fulfil the exact same contract so that the observable behavior of the public + * {@link Proxy} API is independent of the selected engine: + *

    + *
  • Every returned proxy implements {@link InterceptableProxy}.
  • + *
  • {@code toString()}, {@code hashCode()} and {@code equals(Object)} are intercepted, all other {@link Object} + * methods are not.
  • + *
  • An {@link Invocation} handed to an {@link Interceptor} is able to proceed to the real (super) implementation when + * one exists, and throws {@link UnsupportedOperationException} when it does not.
  • + *
+ * + * @see ProxyConfiguration + */ +interface ProxyEngineFactory { + + /** + * Creates a new proxy implementing the given interfaces. + * + * @param + * the type of the proxy + * @param interfaces + * the interfaces to implement. The first element is used as the "host" type that decides in which + * package the generated class ends up. + * + * @return a new proxy instance + */ + T createInterfaceProxy(Class... interfaces); + + /** + * Creates a new proxy for the given interface with generated setter methods for every getter that is missing one. + * The generated setters are only reachable through reflection. + * + * @param + * the type of the proxy + * @param toJavaBeanify + * the interface to turn into a java bean + * + * @return a new java bean proxy instance + */ + T createInterfaceJavaBeanProxy(Class toJavaBeanify); + + /** + * Creates a new proxy that extends the given class. + * + * @param + * the type of the proxy + * @param classToIntercept + * the class to extend + * @param interfaces + * additional interfaces to implement + * + * @return a new proxy instance + */ + T createClassProxy(Class classToIntercept, Class... interfaces); + + /** + * Creates a new proxy that extends the given class, constructed with the given constructor arguments. + * + * @param + * the type of the proxy + * @param classToIntercept + * the class to extend + * @param constructorArgs + * arguments that are passed on to the matching super constructor + * + * @return a new proxy instance + */ + T createClassProxyWithArguments(Class classToIntercept, Object... constructorArgs); + + /** + * Creates a new proxy for the given abstract class with generated setter methods for every getter that is missing + * one. The generated setters are only reachable through reflection. + * + * @param + * the type of the proxy + * @param toJavaBeanify + * the abstract class to turn into a java bean + * @param interfaces + * additional interfaces to implement + * + * @return a new java bean proxy instance + */ + T createClassJavaBeanProxy(Class toJavaBeanify, Class... interfaces); + + /** + * Creates a new proxy that delegates to the given object, unless the object already is a proxy that implements all + * the requested interfaces, in which case the object itself is returned. + * + * @param + * the type of the object + * @param objectToIntercept + * the object to proxy + * @param interfaces + * additional interfaces to implement + * + * @return a proxy instance, or the unchanged object + */ + T createObjectProxyIfNeeded(T objectToIntercept, Class... interfaces); + + /** + * @return the name of this engine, matching {@link ProxyConfiguration.Engine#getName()} + */ + String getEngineName(); +} diff --git a/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineProvider.java b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineProvider.java new file mode 100644 index 0000000..ac8c0a9 --- /dev/null +++ b/src/main/java/com/ericsson/commonlibrary/proxy/ProxyEngineProvider.java @@ -0,0 +1,75 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import java.util.EnumMap; +import java.util.Map; + +import com.ericsson.commonlibrary.proxy.ProxyConfiguration.Engine; + +/** + * Lazily creates and caches one {@link ProxyEngineFactory} per {@link Engine}. Engines are only instantiated when they + * are actually used, so an application that never selects the byte-buddy engine never touches byte-buddy classes. + */ +final class ProxyEngineProvider { + + private static final Map FACTORIES = new EnumMap<>(Engine.class); + + private ProxyEngineProvider() { + // hidden + } + + /** + * @return the factory of the engine that {@link ProxyConfiguration} currently selects. + */ + static ProxyEngineFactory getCurrentFactory() { + return getFactory(ProxyConfiguration.getEngine()); + } + + static ProxyEngineFactory getFactory(Engine engine) { + synchronized (FACTORIES) { + ProxyEngineFactory factory = FACTORIES.get(engine); + if (factory == null) { + factory = createFactory(engine); + FACTORIES.put(engine, factory); + } + return factory; + } + } + + private static ProxyEngineFactory createFactory(Engine engine) { + try { + switch (engine) { + case JAVASSIST: + return new JavassistProxyEngineFactory(); + case BYTEBUDDY: + return new ByteBuddyProxyEngineFactory(); + default: + throw new ProxyException("Unsupported proxy engine: " + engine); + } + } catch (LinkageError e) { + throw new ProxyException("The " + engine.getName() + + " proxy engine is not available on the classpath. Add the missing dependency or select another engine with -D" + + ProxyConfiguration.ENGINE_PROPERTY, e); + } + } +} diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 2eae8d9..dd08a54 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -5,7 +5,8 @@ ## Introduction A small yet powerful interception library that lets you manipulate existing objects and classes behavior runtime, -It's achieving this by using [javassist](http://jboss-javassist.github.io/javassist/) to do bytecode manipulation, +It's achieving this by using [javassist](http://jboss-javassist.github.io/javassist/) or +[Byte Buddy](https://bytebuddy.net/) to do bytecode manipulation, see [Bytecode engines](#bytecode-engines), ### Interesting features @@ -64,3 +65,24 @@ See more info at: [MDC logging](http://www.baeldung.com/mdc-in-log4j-2-logback) ### Example: Simplified explaination on how you can visualize how Proxy actually working. + +## Bytecode engines + +Proxy ships with two interchangeable bytecode manipulation engines, +[javassist](http://jboss-javassist.github.io/javassist/) and [Byte Buddy](https://bytebuddy.net/). +They implement the same behavior, so the engine is purely an implementation detail and no code that uses `Proxy` +has to change when you switch. The whole test suite is executed twice by the build, once per engine, to keep it that way. + +Javassist is the default. Select another engine for the whole JVM with a system property: + +```bash +java -Dproxy.engine=bytebuddy your.Application +``` + +Or select it from code, globally, per thread, or for a single call: + + + +Proxies from both engines can be mixed freely in the same JVM, and a proxy created by one engine can be proxied again by +the other. Already created proxies keep the engine they were created with. + diff --git a/src/test/java/com/ericsson/commonlibrary/proxy/ProxyEngineTest.java b/src/test/java/com/ericsson/commonlibrary/proxy/ProxyEngineTest.java new file mode 100644 index 0000000..0154d82 --- /dev/null +++ b/src/test/java/com/ericsson/commonlibrary/proxy/ProxyEngineTest.java @@ -0,0 +1,278 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package com.ericsson.commonlibrary.proxy; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotSame; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.Callable; + +import org.testng.annotations.AfterMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import com.ericsson.commonlibrary.proxy.ProxyConfiguration.Engine; +import com.ericsson.commonlibrary.proxy.helpobjects.AbstractClassWithImpl; +import com.ericsson.commonlibrary.proxy.helpobjects.MyInterface; +import com.ericsson.commonlibrary.proxy.helpobjects.MySubImpl; +import com.ericsson.commonlibrary.proxy.helpobjects.PersonBean; +import com.ericsson.commonlibrary.proxy.helpobjects.ReturnString1; + +/** + * Verifies that both engines exist in parallel, that the user can switch between them, and that they behave the same. + */ +public class ProxyEngineTest { + + private static final String JAVASSIST_CLASS_MARKER = "_$$_jvst"; + private static final String BYTEBUDDY_CLASS_MARKER = "$ProxyByByteBuddy$"; + + @AfterMethod + public void restoreConfiguredEngine() { + ProxyConfiguration.reset(); + } + + @DataProvider(name = "engines") + public Object[][] engines() { + return new Object[][] { { Engine.JAVASSIST }, { Engine.BYTEBUDDY } }; + } + + @Test + public void defaultEngineIsTheOneFromTheSystemProperty() { + assertEquals(ProxyConfiguration.getEngine(), + Engine.fromName(System.getProperty(ProxyConfiguration.ENGINE_PROPERTY))); + } + + @Test + public void unknownEngineNameFallsBackToJavassist() { + assertEquals(Engine.fromName("does-not-exist"), Engine.JAVASSIST); + assertEquals(Engine.fromName(null), Engine.JAVASSIST); + assertEquals(Engine.fromName(" "), Engine.JAVASSIST); + assertEquals(Engine.fromName(" ByteBuddy "), Engine.BYTEBUDDY); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void nullEngineIsRejected() { + ProxyConfiguration.setEngine((Engine) null); + } + + @Test + public void globalEngineCanBeSwitchedAtRuntime() { + ProxyConfiguration.setEngine(Engine.BYTEBUDDY); + assertEquals(ProxyConfiguration.getEngine(), Engine.BYTEBUDDY); + assertTrue(proxyClassNameOfAClassProxy().contains(BYTEBUDDY_CLASS_MARKER)); + + ProxyConfiguration.setEngine("javassist"); + assertEquals(ProxyConfiguration.getEngine(), Engine.JAVASSIST); + assertTrue(proxyClassNameOfAClassProxy().contains(JAVASSIST_CLASS_MARKER)); + } + + @Test + public void threadEngineOverridesTheGlobalEngine() throws Exception { + ProxyConfiguration.setEngine(Engine.JAVASSIST); + ProxyConfiguration.setThreadEngine(Engine.BYTEBUDDY); + assertEquals(ProxyConfiguration.getEngine(), Engine.BYTEBUDDY); + + ProxyConfiguration.clearThreadEngine(); + assertEquals(ProxyConfiguration.getEngine(), Engine.JAVASSIST); + + String name = ProxyConfiguration.callWithEngine(Engine.BYTEBUDDY, this::proxyClassNameOfAClassProxy); + assertTrue(name.contains(BYTEBUDDY_CLASS_MARKER)); + assertEquals(ProxyConfiguration.getEngine(), Engine.JAVASSIST); + } + + @Test(dataProvider = "engines") + public void classProxyIsCreatedByTheSelectedEngine(Engine engine) throws Exception { + String name = ProxyConfiguration.callWithEngine(engine, this::proxyClassNameOfAClassProxy); + assertTrue(name.contains(markerOf(engine)), name + " was not created by " + engine); + } + + @Test(dataProvider = "engines") + public void interfaceProxyIsCreatedByTheSelectedEngine(Engine engine) throws Exception { + MyInterface proxy = withEngine(engine, () -> Proxy.delegate(MyInterface.class, new MySubImpl())); + assertTrue(proxy.getClass().getName().contains(markerOf(engine))); + assertEquals(proxy.doSomething(), "MySubImpl"); + } + + @Test(dataProvider = "engines") + public void interceptionOfAClassProceedsToTheRealImplementation(Engine engine) throws Exception { + ReturnString1 proxy = withEngine(engine, () -> Proxy.with(ReturnString1.class, "real") + .interceptAll(i -> "before " + i.invoke() + " after").get()); + assertEquals(proxy.returnString1(), "before real after"); + } + + @Test(dataProvider = "engines") + public void interceptionOfAnObjectProceedsToTheRealImplementation(Engine engine) throws Exception { + List list = withEngine(engine, () -> Proxy.intercept(new ArrayList(), + i -> "size".equals(i.getMethodName()) ? 42 : i.invoke())); + list.add("one"); + assertEquals(list.size(), 42); + assertEquals(list.get(0), "one"); + } + + @Test(dataProvider = "engines") + public void interceptionOfAnAbstractClassProceedsToTheImplementedMethodsOnly(Engine engine) throws Exception { + AbstractClassWithImpl proxy = withEngine(engine, + () -> Proxy.with(AbstractClassWithImpl.class).interceptAll(Invocation::invoke).get()); + assertEquals(proxy.getImpl(), "impl"); + try { + proxy.getNotImpl(); + org.testng.Assert.fail("An abstract method has nothing to proceed to"); + } catch (UnsupportedOperationException e) { + assertTrue(e.getMessage().contains("getNotImpl")); + } + } + + @Test(dataProvider = "engines") + public void javaBeanWorksOnBothEngines(Engine engine) throws Exception { + PersonBean bean = withEngine(engine, () -> Proxy.javaBean(PersonBean.class)); + bean.setName("elis"); + bean.setAge(20); + assertEquals(bean.getName(), "elis"); + assertEquals(bean.getAge(), 20); + assertTrue(bean.toString().contains("PersonBean")); + } + + @Test(dataProvider = "engines") + public void generatedJavaBeanSettersAreReachableWithReflectionOnBothEngines(Engine engine) throws Exception { + com.ericsson.commonlibrary.proxy.helpobjects.JavaBean bean = withEngine(engine, + () -> Proxy.javaBean(com.ericsson.commonlibrary.proxy.helpobjects.JavaBean.class)); + bean.getClass().getMethod("setName", String.class).invoke(bean, "generated"); + assertEquals(bean.getName(), "generated"); + } + + @Test(dataProvider = "engines") + public void interceptorsCanBeAddedAndRemovedOnBothEngines(Engine engine) throws Exception { + ReturnString1 proxy = withEngine(engine, () -> Proxy.with(ReturnString1.class, "real").get()); + Interceptor interceptor = i -> "intercepted"; + + Proxy.getProxyInterface(proxy).addInterceptor(interceptor); + assertEquals(proxy.returnString1(), "intercepted"); + assertTrue(Proxy.getProxyInterface(proxy).getInterceptorList().contains(interceptor)); + + Proxy.getProxyInterface(proxy).removeInterceptor(interceptor); + assertEquals(proxy.returnString1(), "real"); + } + + @Test + public void aByteBuddyProxyCanBeWrappedInAJavassistProxy() throws Exception { + ReturnString1 inner = withEngine(Engine.BYTEBUDDY, + () -> Proxy.with(ReturnString1.class, "real").interceptAll(appending("+bytebuddy")).get()); + ReturnString1 outer = withEngine(Engine.JAVASSIST, () -> Proxy.delegate(ReturnString1.class, inner)); + + assertNotSame(outer, inner); + assertTrue(outer.getClass().getName().contains(JAVASSIST_CLASS_MARKER)); + assertEquals(outer.returnString1(), "real+bytebuddy"); + } + + @Test + public void aJavassistProxyCanBeWrappedInAByteBuddyProxy() throws Exception { + ReturnString1 inner = withEngine(Engine.JAVASSIST, + () -> Proxy.with(ReturnString1.class, "real").interceptAll(appending("+javassist")).get()); + ReturnString1 outer = withEngine(Engine.BYTEBUDDY, () -> Proxy.delegate(ReturnString1.class, inner)); + + assertNotSame(outer, inner); + assertTrue(outer.getClass().getName().contains(BYTEBUDDY_CLASS_MARKER)); + assertEquals(outer.returnString1(), "real+javassist"); + } + + @Test + public void interceptorsFromBothEnginesStackOnTheSameProxy() throws Exception { + ReturnString1 proxy = withEngine(Engine.BYTEBUDDY, + () -> Proxy.with(ReturnString1.class, "real").interceptAll(appending("+first")).get()); + // Intercepting an already existing proxy reuses it, no matter which engine is configured at the time. + ReturnString1 same = withEngine(Engine.JAVASSIST, () -> Proxy.intercept(proxy, appending("+second"))); + + assertSame(same, proxy); + assertEquals(proxy.returnString1(), "real+first+second"); + } + + /** + * Only alters returnString1(), so that hashCode() and toString() of the proxy keep working. + */ + private static Interceptor appending(String suffix) { + return i -> "returnString1".equals(i.getMethodName()) ? i.invoke() + suffix : i.invoke(); + } + + @Test(dataProvider = "engines", expectedExceptions = ProxyException.class) + public void finalClassesAreRejectedByBothEnginesWithTheSameException(Engine engine) throws Exception { + withEngine(engine, () -> Proxy.intercept("a final class", i -> i.invoke())); + } + + /** + * Both features refuse to touch classes that expose public instance variables, so a generated proxy class must not + * expose any itself. + */ + @Test(dataProvider = "engines") + public void mdcAndRecursiveInterceptionAreNotRefusedOnAProxyFromEitherEngine(Engine engine) throws Exception { + ReturnString1 proxy = withEngine(engine, () -> Proxy.with(ReturnString1.class, "real").get()); + Deque interceptors = Proxy.getProxyInterface(proxy).getInterceptorList(); + + int size = interceptors.size(); + withEngine(engine, () -> Proxy.mdcLogging(proxy, "key", "value")); + assertEquals(interceptors.size(), size + 1, "the mdc interceptor was silently refused"); + + withEngine(engine, () -> Proxy.recursiveIntercept(proxy, Invocation::invoke)); + assertEquals(interceptors.size(), size + 2, "the recursive interceptor was silently refused"); + + assertEquals(proxy.returnString1(), "real"); + } + + /** + * A new generated class per proxy instance would make metaspace grow without bound in a long running application. + */ + @Test(dataProvider = "engines") + public void generatedProxyClassesAreReusedForTheSameShape(Engine engine) throws Exception { + Class first = withEngine(engine, () -> Proxy.with(ReturnString1.class, "one").get().getClass()); + Class second = withEngine(engine, () -> Proxy.with(ReturnString1.class, "two").get().getClass()); + assertSame(second, first, "a new proxy class was generated for an identical proxy shape"); + } + + @Test(dataProvider = "engines") + public void generatedProxyClassesExposeNoPublicInstanceFields(Engine engine) throws Exception { + Class proxyClass = withEngine(engine, () -> Proxy.with(ReturnString1.class, "real").get().getClass()); + for (Field field : proxyClass.getDeclaredFields()) { + assertFalse( + Util.isPublicAndNotStaticFinal(field.getModifiers()) + && !"_filter_signature".equals(field.getName()), + proxyClass.getName() + " exposes the public field " + field.getName()); + } + } + + private String proxyClassNameOfAClassProxy() { + return Proxy.with(ReturnString1.class, "real").get().getClass().getName(); + } + + private static String markerOf(Engine engine) { + return engine == Engine.JAVASSIST ? JAVASSIST_CLASS_MARKER : BYTEBUDDY_CLASS_MARKER; + } + + private static T withEngine(Engine engine, Callable callable) throws Exception { + return ProxyConfiguration.callWithEngine(engine, callable); + } +} diff --git a/src/test/java/examples/ProxyEngineExample.java b/src/test/java/examples/ProxyEngineExample.java new file mode 100644 index 0000000..614d984 --- /dev/null +++ b/src/test/java/examples/ProxyEngineExample.java @@ -0,0 +1,75 @@ +/* +Copyright (c) 2018 Ericsson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +package examples; + +import static com.ericsson.commonlibrary.proxy.Proxy.with; + +import com.ericsson.commonlibrary.proxy.ProxyConfiguration; +import com.ericsson.commonlibrary.proxy.ProxyConfiguration.Engine; + +public class ProxyEngineExample { + + public static class SomeImpl { // sample class + + public String greet(String name) { + return "hello " + name; + } + } + + public static void main(String[] args) throws Exception { + + // Javassist is the default engine, nothing has to be configured. + System.out.println(intercepted().greet("javassist")); + // Console output: + // before greet + // hello javassist + + // Switch the whole JVM over to byte-buddy. The API and the behavior are identical. + ProxyConfiguration.setEngine(Engine.BYTEBUDDY); + System.out.println(intercepted().greet("bytebuddy")); + // Console output: + // before greet + // hello bytebuddy + + // The generated classes differ, the behavior does not. + System.out.println(intercepted().getClass().getName()); + // Console output: + // examples.ProxyEngineExample_SomeImpl$ProxyByByteBuddy$2 + + // Back to whatever -Dproxy.engine says, or javassist when it says nothing. + ProxyConfiguration.reset(); + + // A single call can pick its own engine without disturbing any other thread. + SomeImpl onlyThisOne = ProxyConfiguration.callWithEngine(Engine.BYTEBUDDY, ProxyEngineExample::intercepted); + System.out.println(onlyThisOne.greet("scoped")); + // Console output: + // before greet + // hello scoped + } + + private static SomeImpl intercepted() { + return with(SomeImpl.class).interceptAll(i -> { + System.out.println("before " + i.getMethodName()); + return i.invoke(); + }).get(); + } +}