Skip to content
Adrian Papari edited this page Dec 30, 2019 · 39 revisions

graftt

Rewrite existing classes by grafting bytecode from Transplant classes. Transplants are plain java classes and function like templates or blueprints; annotations define interactions with the recipient class. The entire API consists of 4 primary annotations.

Transplants have complete access to the recipient class: Existing methods can be wrapped, changed or replaced entirely. Interfaces can be retrofitted and additional fields added. Update annotations on classes, methods and fields.

Terminology

In the interest of brevity.

  • agent: the java agent responsible for applying transplants at load-time.
  • donor: transplants are occasionally referred to as donor, especially in the source.
  • fuse/fusing: merges transplant method with matching method in recipient. Implies @Graft.Fuse.
  • transplant (class): class annotated with @Graft.Recipient. Transplants bytecode to the recipient.
  • recipient (class): the target of @Graft.Recipient(target).

API

Using the API revolves around writing a template representing the actual methods and fields that should be copied over. Transplants can reference internal symbols by annotating them with @Graft.Mock in the transplant.

Adding a custom toString() to a final class on the classpath can be as simple as:

@Graft.Recipient(Foo.class) // all transplants have a recipient
public class FooTransplant {

    @Graft.Mock // simulating Foo.toDebugString
    private String toDebugString() { 
        return null; // never called, return value is unimportant
    } 

    @Graft.Fuse // "fusing" (here: replacing) existing Foo::toString
    public String toString() {
        return "hello world: " + toDebugString();
    }
}
  • @Graft.Recipient specifies which class to transplant to.
  • @Graft.Fuse transplants bytecode over to @Graft.Recipient, translating any FooTransplant references to Foo. Call the original method at any time by invoking the method currently being fused; e.g. Fusing FooTransplant::bar with Foo::bar, any call to bar() inside the transplant will point to Foo::bar$original once applied.
  • @Graft.Mock to keep the compiler happy when you need to reference fields or methods in the target class. Mocked references point to target class after transplant.
  • @Graft.Annotations configures removal and updating of annotations. The default behavior copies all annotations from the transplant class and its symbol over to the recipient.
  • All interfaces are copied over to recipient.
  • All fields and methods, except those annotated with @Graft.Mock, are copied to recipient.

For fused methods, the original method is renamed with "$original" suffixed to the name. The original method only exists if it was invoked by the transplant, otherwise the fused method replaces the original.

It is good taste to add the -Transplant suffix to these classes.

How-to

A short, but mostly thorough description of each feature.

Add field to class

To copy a new field, it's enough to declare it:

@Graft.Recipient(Foo.class)
public class FooTransplant {
    private int randomNumber = 4;
}

Only primitive fields can be initialized with a value. The below transplant will hence not work.

@Graft.Recipient(Foo.class)
public class FooTransplant {
    private ArrayList<String> yolo = new ArrayList<>(); // :(    
}

Add method to class

As with fields, methods are transplanted by default:

@Graft.Recipient(Foo.class)
public class FooTransplant {
    public void hello(String s) {
        System.out.println("hello " + s);
    }
}

Fuse(/add) method in class

Wrap a method with @Graft.Fuse. The annotated method's signature must match a method in the recipient class.

A fused method can invoke the original method by simply calling itself.

@Graft.Recipient(Foo.class)
public class FooTransplant {
    
    @Graft.Fuse    
    public void hello(String s) {
        long start = System.currentTimeMillis();
        hello(s); // calls original method Foo::hello
        long end = System.currentTimeMillis();    
        System.out.println("hello(s) executed in: " + (end - start));
    }
}

Replace method in class

Fusing a method without invoking the original method completely removes the original method from the recipient.

@Graft.Recipient(Foo.class)
public class FooTransplant {
    
    @Graft.Fuse // matching Foo::hello    
    public void hello(String s) {
        // never calling Foo.hello(s) - original is deleted
        System.out.println("good bye " + s);
    }
}

Add annotations

Annotations on transplant class, fields and methods are copied over by default. None of Graft.* annotations are transplanted.

@Graft.Recipient(Foo.class)
@MyAnnotation
public class FooTransplant {
    
    @MyAnnotation
    private int i;    
    
    @MyAnnotation
    @Graft.Fuse // <- matching Foo::otherField
    private int otherField;    

    @Graft.Fuse
    @MyAnnotation     
    public void hello(String s) {
        hello(s);
    }
    
    @MyAnnotation
    public void goodBye(String s) {
        System.out.println("good bye " + s);
    }
}

Fused methods retain all annotations decorating the recipient's original method.

Update or remove annotation on class, method and field

Removing SomeAnno from Foo:

@Graft.Recipient(Foo.class)
@Graft.Annotations(remove = SomeAnno.class)
public class FooTransplant {}

Removing SomeAnno from field in Foo - note that Graft.Fuse is required:

@Graft.Recipient(Foo.class)
public class FooTransplant {

    @Graft.Fuse
    @Graft.Annotations(remove = SomeAnno.class)
    private byte[] someField;
}

Removing SomeAnno from method in Foo - note that Graft.Fuse is required:

@Graft.Recipient(Foo.class)
public class FooTransplant {

    @Graft.Fuse
    @Graft.Annotations(remove = SomeAnno.class)
    private boolean someMethod() {
        return someMethod();    
    }
}

@Graft.Annotations(overwrite = true) indicates that transplanted annotations replace any matching annotation in the recipient:

@Graft.Recipient(Foo.class)
@Graft.Annotations(overwrite = true)
@SomeAnno(2)
public class FooTransplant {

    @Graft.Fuse
    @Graft.Annotations(overwrite = true)
    @SomeAnno(3)
    private byte[] someField;
    
    @Graft.Fuse
    @Graft.Annotations(overwrite = true)
    @SomeAnno(1)
    private boolean someMethod() {
        return someMethod();    
    }

}

Add interface to class

Any interfaces implemented by a transplanted are added to the recipient.

@Graft.Recipient(Foo.class)
class FooTransplant implements Bar { // Bar interface transplanted to Foo

    @Override
    public int hi() { return "hello"; }
}

Note that only interfaces are supported; changing the parent class is not supported.

Transplant and recipient are interchangeable in transplants

Within a transplant class, any references to transplants are translated to their recipient type. This way it is possible to invoke methods added through transplants.

// adding getName() to Bar
@Graft.Recipient(Bar.class)
public class BarTransplant {
    @Graft.Mock // mocking Bar::name for getName()
    private String name;    

    public String getName() {
        return name;    
    }
}

// holds Bars in a list
public class Foo {
    private List<Bar> children = new ArrayList<>();

    public void add(Bar bar) {
        children.add(bar);
    }
}


@Graft.Recipient(Foo.class)
public class FooTransplant {

    @Graft.Mock // mock for List<Bar> children
    private List<BarTransplant> children = new ArrayList<>();

    @Graft.Fuse // fusing with Foo::add(Bar)
    public void add(BarTransplant bar) { 
        System.out.println(bar.getName());
        children.add(bar);
    }
}

Mocking fields and methods

Mocks serve two purposes:

  1. When there is a need to reference fields or methods in recipient.
  2. Mocking is also a requirement for compilation to pass in the above case.
@Graft.Recipient(Foo.class)
public class FooTransplant {
    
    @Graft.Mock
    private void clean() { }
    
    @Graft.Mock
    public int lastResult;
    
    @Graft.Fuse
    public int execute() {
        clean();                // Foo.clean()
        lastResult = execute(); // Foo.execute()
        return lastResult;
    }
}

Mocked fields may also annotate another transplant type:

@Graft.Mock // replaced with `Foo` after transplant
private FooTransplant foo;

Mocked fields and methods have no effect on the recipient.

Ad-hoc mocking external classes

It is sometimes necessary to create additional placeholder classes for the transplants to compile - e.g. when a class isn't visible due to package visibility, but the class must still be referenced by a transplant.

Such classes are never transplanted as they're not a part of any transplant.

class InternalClass {
    public int something() {
        throw new IllegalStateException("i'm never invoked");
    }
}

@Graft.Recipient(Foo.class)
public class FooTransplant {
    @Graft.Mock
    private InternalClass internalStuff;

    @Graft.Fuse
    public int execute() {
        return internalStuff.something();
    }
}

Limitations and caveats

  • Not yet possible to append to constructor or static initializer.
  • Transplanted fields are initialized to null or 0.
  • Parent class of recipient cannot be changed.
  • Not possible to remove fields, methods or interfaces.
  • No support for multiple transplants denoting the same recipient; will fail if both try to @Graft.Fuse with the same method.

Some of these shortcomings will be addressed in a later versions.

Additionally, supporting classes from JVM languages other than java is not supported; it should work for simple use-cases, but would not support any language features present in e.g. kotlin.

Kotlin support is considered.

Usage

Two means of grafting transplants are available out of the box:

  • build-time maven plugin: transplanting during compilation. After compilation, resulting classes have been transformed by matching transplants.
  • load-time java agent: transplanting before class reaches the classloader. Transformed classes exist only in memory and die when the application ends.

The maven plugin and agent differ in which classes are eligible for transplant. The maven plugin is restricted to only transplanting to classes under target/classes, while the agent has free reign over where recipient classes originate from.

The use-case for the maven plugin is to conditionally activate the plugin when building debug or similar variants. As the transformed class files are updated, this approach is also compatible with android and other environments where running a java agent isn't feasible.

Using the java agent

The agent works by transforming classes before they're loaded by the classloader.

There are two ways registering transplants:

  • Transplants on the project classpath: transplants are registered by instantiation.
  • Pass root directories and path/to/jar: transplants are registered automatically.

Default behavior: must reference transplants

When no arguments are passed to the agent, each transplant has to be instantiated (loaded by the classloader) for it to be registered.

# all transplants live on classpath; registration through instantiation
java -javaagent:agent-${VERSION}.jar ...

Once a Transplant is loaded by the agent, it is registered; the transplant is applied when its recipient class is loaded. It is therefore important to make sure that any local transplants are registered before the recipient class is loaded.

fun main(args : Array<String>) {
    FooTransplant()
    BarTransplant()

cp: paths and jars

The cp argument specifies a , delimited list of paths. Each path must denote an existing directory or .jar file.

All paths are scanned recursively for transplants prior to the application loading any classes. As such, there is no need to explicitly instantiate transplants found in cp paths.

# transplants from cp args are auto-registered
java -javaagent:agent-${VERSION}.jar=cp=/path1/to/dir,/path/to/jar ...

Using the maven plugin

The maven plugin applies transplants during the process-classes lifecycle phase.

Transplants found locally under target/classes are applied automatically. Additional transplants can be passed as dependencies, jars and root class directories.

Running with the default configuration - only applying transplants from target/classes:

<plugin>
    <groupId>net.onedaybeard.graftt</groupId>
    <artifactId>graftt-maven-plugin</artifactId>
    <version>${project.version}</version>
    <executions>
        <execution>
            <id>graftt</id>
            <goals>
                <goal>graftt</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Transplants from path

A path must resolve to an existing jar file or path. Directories and jar files are searched recursively for Transplants.

If the <path> element denotes a directory, only .class files are inspected. Any .jar files that happen to be under the directory tree are ignored.

<plugin>
    <groupId>net.onedaybeard.graftt</groupId>
    <artifactId>graftt-maven-plugin</artifactId>
    <version>${graftt.version}</version>
    <executions>
        <execution>
            <id>graftt</id>
            <goals>
                <goal>graftt</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <!-- paths to root of class directories or .jar files -->
        <paths>
            <path>/path/to/transplants</path>
            <path>/path/to/transplants.jar</path>
        </paths>
    </configuration>
</plugin>

Transplants from dependencies

Transplants can also come from maven dependencies. The plugin inspects all transplants from its own dependencies. For artifact resolution to work, the dependency (unfortunately) also has to be added to the pom.xml's dependencies - otherwise the referenced jar files aren't resolved.

    <dependencies>
        <!-- resolving referenced .jar file -->
        <dependency>
            <groupId>com.mydomain.foo</groupId>
            <artifactId>transplants</artifactId>
            <version>${foo.version}</version>
            <!-- don't need transplants at runtime -->
            <scope>provided</scope>
        </dependency>
    </dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>net.onedaybeard.graftt</groupId>
            <artifactId>graftt-maven-plugin</artifactId>
            <version>${graftt.version}</version>
            <executions>
                <execution>
                    <id>graftt</id>
                    <goals>
                        <goal>graftt</goal>
                    </goals>
                </execution>
            </executions>
            <dependencies>
                <!-- registering transplants with plugin -->
                <dependency>
                    <groupId>com.mydomain.foo</groupId>
                    <artifactId>transplants</artifactId>
                    <version>${foo.version}</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

The maven plugin rewrites classes under the active project's target/classes. Any Transplant classes in the directory are applied automatically. Remaining transplants are sourced from //graft-maven-plugin/configuration/paths.

Custom tools

core module

TODO

Recipes

The core tests cover most transplant use-cases; they show what is possible and how to accomplish it.

Recipe: add toString to object

@Graft.Recipient(ClassNode.class)
public class ClassNodeTransplant {
    
    @Graft.Mock
    public String name;
    
    public String toString() {
        return "ClassNode(" + name + ")";
    }
}

Recipe: retrofit interface

Any interfaces implemented by the transplant are carried over to the recipient.

@Graft.Recipient(Foo.class)
public class FooTransplant implements Point {
    @Graft.Mock private int x = 1;
    @Graft.Mock private int y = 2;

    @Override
    public int x() {
        return x;
    }

    @Override
    public int y() {
        return y;
    }
}

If Foo already declares private non-public interface methods with matching names - x() and y() - one option is to wrap the original method:

@Graft.Recipient(Foo.class)
public class FooTransplant implements Point {

    @Graft.Fuse @Override
    public int x() { return x(); }

    @Graft.Fuse @Override
    public int y() { return y(); }
}

Clone this wiki locally