Search code examples
cachingjava-8byte-buddyvavr

How to cache an instrumented class with an instance forwarder?


The use case is to implement a dirty field tracker. For this I have an interface:

public interface Dirtyable {

    String ID = "dirty";

    Set<String> getDirty();

    static <T> T wrap(final T delegate) {
        return DirtyableInterceptor.wrap(delegate, ReflectionUtils::getPropertyName);
    }

    static <T> T wrap(final T delegate, final Function<Method, String> resolver) {
        return DirtyableInterceptor.wrap(delegate, resolver);
    }

}

In the interceptor class the wrapping method is:

static <T> T wrap(final T delegate, final Function<Method, String> resolver) {
    requireNonNull(delegate, "Delegate must be non-null");
    requireNonNull(resolver, "Resolver must be non-null");

    final Try<Class<T>> delegateClassTry = Try.of(() -> getClassForType(delegate.getClass()));

    return delegateClassTry.flatMapTry(delegateClass ->
            dirtyableFor(delegate, delegateClass, resolver))
            .mapTry(Class::newInstance)
            .getOrElseThrow(t -> new IllegalStateException(
                    "Could not wrap dirtyable for " + delegate.getClass(), t));
}

The method dirtyableFor defines a ByteBuddy which forwards to a specific instance at each call. However, instrumenting at every invocation is a bit expensive so it caches the instrumented subclass from the given instance's class. For this I use the resilience4j library (a.k.a. javaslang-circuitbreaker).

private static <T> Try<Class<? extends T>> dirtyableFor(final T delegate,
                                                        final Class<T> clazz,
                                                        final Function<Method, String> resolver) {

    long start = System.currentTimeMillis();

    Try<Class<? extends T>> r = Try.of(() -> ofCheckedSupplier(() ->
            new ByteBuddy().subclass(clazz)
                    .defineField(Dirtyable.ID, Set.class, Visibility.PRIVATE)
                    .method(nameMatches("getDirty"))
                    .intercept(reference(new HashSet<>()))
                    .implement(Dirtyable.class)
                    .method(not(isDeclaredBy(Object.class))
                            .and(not(isAbstract()))
                            .and(isPublic()))
                    .intercept(withDefaultConfiguration()
                            .withBinders(Pipe.Binder.install(Function.class))
                            .to(new DirtyableInterceptor(delegate, resolver)))
                    .make().load(clazz.getClassLoader())
                    .getLoaded())
            .withCache(getCache())
            .decorate()
            .apply(clazz));

    System.out.println("Instrumentation time: " + (System.currentTimeMillis() - start));

    return r;
}

private static <T> Cache<Class<? super T>, Class<T>> getCache() {

    final CachingProvider provider = Caching.getCachingProvider();
    final CacheManager manager = provider.getCacheManager();

    final javax.cache.Cache<Class<? super T>, Class<T>> cache =
            manager.getCache(Dirtyable.ID);

    final Cache<Class<? super T>, Class<T>> dirtyCache = Cache.of(cache);
    dirtyCache.getEventStream().map(Object::toString).subscribe(logger::debug);

    return dirtyCache;
}

From the logs, the intrumentation time drops from 70-100ms for a cache miss to 0-2ms for a cache hit.

For completeness here is the interceptor method:

@RuntimeType
@SuppressWarnings("unused")
public Object intercept(final @Origin Method method, final @This Dirtyable dirtyable,
                        final @Pipe Function<Object, Object> pipe) throws Throwable {

    if (ReflectionUtils.isSetter(method)) {
        final String property = resolver.apply(method);
        dirtyable.getDirty().add(property);

        logger.debug("Intercepted setter [{}], resolved property " +
                "[{}] flagged as dirty.", method, property);
    }

    return pipe.apply(this.delegate);
}

This solution works well, except that the DirtyableInterceptor is always the same for cache hits, so the delegate instance is also the same.

Is it possible to bind a forwarder to a supplier of an instance so that intercepted methods would forward to it? How could this be done?


Solution

  • You can create a stateless interceptor by making your intercept method static. To access the object's state, define two fields on your subclass which you access using the @FieldValue annotations in your now static interceptor. Instead of using the FixedValue::reference instrumentation, you would also need to use the FieldAccessor implementation to read the value. You also need to define the fields using the defineField builder method.

    You can set these fields either by:

    1. Adding setter methods in your Dirtyable interface and intercepting them using the FieldAccessor implementation.
    2. Defining an explicit constructor to which you supply the values. This also allows you to define the fields to be final. To implement the constructor, you first need to invoke a super constructor and then call the FieldAccessor several times to set the fields.

    Doing so, you have created a fully stateless class that you can reuse but one that you need to initialze. Byte Buddy already offers a built-in TypeCache for easy reuse.