Skip to content
Adrian Papari edited this page Apr 19, 2020 · 39 revisions

graftt

Rewrite existing classes by grafting bytecode from Transplant classes. Transplants are plain java classes and function like templates or patches; annotations define interactions with the recipient class. The entire API consists of 4 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 overrides default configuration for removal and updating of annotations. The default behavior copies all annotations from the transplanted elements to the recipient.
  • Interfaces implemented by the transplant are added to the 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. Any annotations decorating the original methods are moved to the transplanted method.

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<>(); // :(    
}

Fuse field to change modifiers

It is sometimes convenient to change the modifiers of a field, such as removing final or increasing the visibility. If Foo.bar is a private final field, the transplant would take a form similar to:

@Graft.Recipient(Foo.class)
public class FooTransplant {
    @Graft.Fuse        // identifies field by name and type
    protected Bar bar; // private -> protected and remove final
}

All modifiers on the field are replaced by the modifiers from the fused field. Note that some modifiers can cause surrounding code to break, e.g. adding final or static to a field.

It is not possible to change the type of a field, but if a transplant for the type exists, it can be used in its place:

@Graft.Recipient(Foo.class)
public class FooTransplant {
    @Graft.Fuse
    protected BarTransplant bar;
}

In the above snippet - if field modifiers don't need to be updated on, @Graft.Mock is recommended as it leaves the original field intact.

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 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. No Graft.* annotations are transplanted however.

@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 transplant are added 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; }
}

In a situation where 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(); } // Foo.x() is non-public

    @Graft.Fuse @Override
    public int y() { return y(); } // Foo.y() is non-public
}

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

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();
    }
}

Transplant and recipient are interchangeable inside 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);
    }
}

Limitations and caveats

  • TODO: Donor's lambdas and inner classes are not transplanted.
  • TODO: Not yet possible to append to constructor or static initializer.
  • Transplanted fields are initialized to null or 0. Primitive fields can hold values, and string fields are eligible if the string points to a constant.
  • Parent class of recipient cannot be changed.
  • Not possible to remove fields, methods or interfaces. Methods can be replaced but not removed.
  • No support for multiple transplants denoting the same recipient; will fail if both try to @Graft.Fuse with the same method.

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:

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

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

The use-case for transplanting with 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.

The other use-case for the plugin is generate graftt.index files, used by the agent to automate transplant discovery.

Using the java agent

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

There are three ways the agent registers transplants:

  • Transplants listed in /graftt.index files on the classpath are registered automatically.
  • By referencing the transplants directly, e.g. by calling Class.forName("com.mycompany.transplant.MyLittleTransplant").
  • From root directories and path/to/jar passed to agent: such transplants are not required to reside on the classpath.

Automatic registration from /graftt.index files

During start-up, the agent reads all /graftt.index present on the classpath and registers the transplants listed therein.

The most straightforward approach is to generate the index using the generate-index goal - it is then enough to include the artifact as a dependency.

    <plugin>
        <groupId>net.onedaybeard.graftt</groupId>
        <artifactId>graftt-maven-plugin</artifactId>
        <version>${graftt.version}</version>
        <executions>
            <execution>
                <id>scan-for-transplants</id>
                <goals>
                    <goal>generate-index</goal>
                </goals>
            </execution>
        </executions>
    </plugin>

Alternatively, graftt.index can be created manually too. It takes the following form:

# blank lines and lines starting with '#' are ignored.
# in real life, usually generated by: graftt-maven-plugin:generate-index

# qualified name of each transplant
net.onedaybeard.graftt.FooTransplant
net.onedaybeard.graftt.BarTransplant

Load by referencing transplants

When no arguments are passed to the agent, and no graftt.index exists for all transplants, remaining transplants must be loaded by classloader in order to register them.

# all transplants live on classpath; registration by referencing transplants
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::class
    BarTransplant::class

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 provides the following goals:

  • generate-index: generates graftt.index, automatically picked up by agent
  • transplant: applies transplants during build

Goal: generate-index

Scans target/classes for local transplants and records them in target/classes/graftt.index. This file is used by the agent to register all listed transplants during start-up.

    <plugin>
        <groupId>net.onedaybeard.graftt</groupId>
        <artifactId>graftt-maven-plugin</artifactId>
        <version>${graftt.version}</version>
        <executions>
            <execution>
                <id>scan-for-transplants</id>
                <goals>
                    <goal>generate-index</goal>
                </goals>
            </execution>
        </executions>
    </plugin>

As the agent scans the classpath for graftt.index files, the artifact must be included as a runtime dependency.

Goal: transplant

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>transplant</id>
            <goals>
                <goal>transplant</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 + ")";
    }
}