We’ve all been there before. You’re working on a project with some large inherited codebase, from code elder gods who cannot be reasoned with. Or, you’re interacting with a library, that just does this one annoying thing coming from some terrible decision, and now you need to work around it.
Forking it might be an option, but do you really want to take on that responsibility for this one off thing? And do you really want to challenge the senior elder gods? Your situation is feeling more dire with every class you decompile, no way you’re picking all of this up.
The go-to method within modding communities is to setup mixins, or even dabble with the occasional javaassist, which does the job, but is fragile and breaks between versions. Or maybe the code you write now may run in weird configurations or security policies, in which case, you certainly don’t want to work with either.
If only there was some vanilla way in Java, that when mastered, will allow you to bodge through pretty much anything.
Foreshadowing: a literary device in which a writer gives an advance hint of what is to come later in the story
Bodging
Tom Scott explained it best in his video. It’s the art of patching something ugly together, that works, just for now, or as a permanent solution if you’re willing to ignore the war crimes you committed in the form of code. Java is no stranger to either, and though that can lead some pitfalls and boilerplate abstraction hell, this is one of the cases where you can play god, and literally bend classes at your will during runtime.
Introduction to proxies
Proxies, best known from their JavaScript implementation, are special elements that let you intercept calls and interactions with an object.
const target = { name: 'John', age: 30 }; const handler = { get: (target, prop) => prop in target ? target[prop] : `No such property: ${prop}`, set: (target, prop, value) => prop === 'age' && value <= 0 ? false : (target[prop] = value), deleteProperty: (target, prop) => prop === 'name' ? false : delete target[prop] }; const proxy = new Proxy(target, handler); console.log(proxy.name); // John proxy.age = 25; // Sets age to 25 console.log(proxy.age); // 25 proxy.age = -5; // Invalid age, doesn't change console.log(proxy.age); // 25
This is something that works really well in JS land, types are only a suggestion after all. There’s no governing body running around, telling you that they wouldn’t be compatible.
But, what if I tell you that Java is more forgiving than you might think? And it would let you do similar gymnastics as well. You might not care as much, but this stuff absolutely rocked my world.
Java Types - why is this so powerful
Types in java are strict. You may simply not cast 7
to a 2001 Toyota corolla
no matter how hard you try. If a field is marked as a specific object type, then there’s no way to change it’s type to a different family (maybe if you were to play with Unsafe, but we’re talking about bodging here, not inventing new means of self destruction).
The only way to overwrite existing behavior is to extend the type you’re targeting and only implement the methods you’re trying to manipulate, passing the rest back to super. This works, but is technical debt when the target type is outside of your control and may be updated (with new methods, signatures, or even worse).
Java Proxies
Yes, they exist, and they work. It’s one of the exceptions where java will actually generate new class definitions during runtime, and it is awesome.
They work by creating a new real class during runtime, and registering it directly in JavaLanguageAccess (which is really interesting on it own, and i highly recommend looking into), and it is even smart enough to cache the generated class for future use (when you want to generate a new proxy of the same type).
To get started, we need to create a new InvocationHandler
that will intercept the calls to the object we’re trying to proxy. This is a functional interface, so we can use a lambda to implement it.
InvocationHandler handler = (proxy, method, args) -> { if (method.getName().equals("toString")) { return "Hello from the proxy!"; } return null; };
Next, we need to create the proxy itself. This is done by using the Proxy.newProxyInstance
method, which takes a classloader, an array of interfaces we want to proxy, and the handler we just created. For the examples moving forward, we'll be manipulating a bukkit Player object, and we'll be using the Player
interface as the target.
Player player = (Player) Proxy.newProxyInstance( Player.class.getClassLoader(), new Class[] { Player.class }, handler );
And voila, we have a proxy object that will intercept calls to the Player
object, and return Hello from the proxy!
when toString
is called.
But, this isn't useful in the real world, because this will explode when you try to call any other method on the object, but this is also where the beauty of proxies comes in. You can now implement the methods you want to intercept, and pass the rest back to the original object dynamically, without having to implement the entire interface or even worrying about future changes because it'll be evaluated during runtime, and your used api version doesn't matter.
Where is this useful?
Example 1: Faking permissions
This may sound far fetched, but is a diabolical thing I had to do in the past. I was calling out to a library which was not worth the effort to fork, and it insisted that the passed player had to be OP. Though it's possible to just wrap that call with a setOP invocation, I'd rather not give the player any OP permissions ever
Well, we can use a proxy to work around this! I wrote this little middleware, that only intercepts the isOp method, and returns our custom value, but still passes the rest of the calls to the original object.
public class PlayerOpProxy implements InvocationHandler { private boolean opStatus; private final Player originalPlayer; private Player proxiedPlayer; public PlayerOpProxy(Player originalPlayer, boolean isOp) { this.originalPlayer = originalPlayer; this.proxiedPlayer = (Player) Proxy.newProxyInstance( originalPlayer.getClass().getClassLoader(), originalPlayer.getClass().getInterfaces(), this ); } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getName().equals("isOp")) { return this.opStatus; } else if (method.getName().equals("setOp")) { this.opStatus = (boolean) args[0]; return null; } else { return method.invoke(this.originalPlayer, args); } } public Player getProxiedPlayer() { return this.proxiedPlayer; } }
Example 2: Function middleware (caching)
This is a more practical example, where we want to cache the result of a function call, and return the cached value if the function is called. Normally we would have to implement a caching strategy ourself, but with a proxy we can abstract all of this as if it were a code generator (which it is, in a way).
If we use a proxy based method, then all we need to do is
public interface UserService { @CacheUtil.Cache(retention = 5) String getUserName(int id); } // Creating a proxy UserService service = new UserServiceImpl(); UserService proxiedService = CacheUtil.createProxy(service, UserService.class); // Using the proxied service String result = proxiedService.getUserName(1); // calls the original method String cachedResult = proxiedService.getUserName(1); // returns the cached value, never actually reaching the original method
And this entirely works without any external dependencies, and without requiring ahead of time binary manipulation or mixins. It's a simple, clean, and effective way to manipulate objects at runtime, and it's a tool that I'm surprised isn't used more often.
Here's my CacheUtil implementation, as a freebie for reading this far.
public final class CacheUtil { /** * Annotation to specify caching behavior for methods. * Methods annotated with {@code @Cache} will have their return values cached * for the specified retention time in seconds. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Cache { /** * Specifies how long the cached value should be retained in seconds. * @return retention time in seconds */ int retention() default 5; } /** * Creates a proxy instance that implements caching behavior for annotated methods. * * @param <T> the type of the interface to be proxied * @param target the object to be proxied * @param interfaceClass the interface class that the proxy will implement * @return a proxied instance with caching behavior * @throws IllegalArgumentException if target or interfaceClass is null */ @SuppressWarnings("unchecked") public static <T> T createProxy(T target, Class<T> interfaceClass) { if (target == null || interfaceClass == null) { throw new IllegalArgumentException("Target and interface class cannot be null"); } return (T) Proxy.newProxyInstance( interfaceClass.getClassLoader(), new Class<?>[] { interfaceClass }, new CacheHandler(target) ); } private static class CacheEntry { private final Object value; private final long expirationTime; CacheEntry(Object value, long expirationTime) { this.value = value; this.expirationTime = expirationTime; } boolean isExpired() { return System.currentTimeMillis() > expirationTime; } Object getValue() { return value; } } private static class CacheHandler implements InvocationHandler { private final Object target; private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>(); CacheHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Cache cacheAnnotation = method.getAnnotation(Cache.class); if (cacheAnnotation == null) { return method.invoke(target, args); } String cacheKey = generateCacheKey(method, args); CacheEntry entry = cache.get(cacheKey); if (entry != null && !entry.isExpired()) { return entry.getValue(); } Object result = method.invoke(target, args); long expirationTime = System.currentTimeMillis() + (cacheAnnotation.retention() * 1000L); cache.put(cacheKey, new CacheEntry(result, expirationTime)); return result; } private String generateCacheKey(Method method, Object[] args) { return method.getName() + ":" + (args != null ? Arrays.deepHashCode(args) : ""); } } }