1. Introduction
In software development, we often need to adapt and enhance our systems’ existing functionalities. Sometimes, modifying the existing codebase may not be possible or may not be the most pragmatic solution. Therefore, a solution to this problem is monkey patching. This technique allows us to modify a class or module runtime without altering its original source code.
In this article, we’ll explore how monkey patching can be used in Java, when to use it, and its drawbacks.
2. Monkey Patching
The term monkey patching originates from an earlier term, guerilla patch, which refers to changing code sneakily at runtime without any rules. It gained popularity thanks to the flexibility of dynamic programming languages, such as Java, Python, or Ruby.
Monkey patching enables us to modify or extend classes or modules at runtime. This allows us to tweak or augment existing code without requiring direct alterations to the source. It is particularly useful when adjustments are imperative, but direct modification is either unfeasible or undesirable due to various constraints.
In Java, monkey patching can be achieved through various techniques. These methods include proxies, bytecode instrumentation, aspect-oriented programming, reflection, or decorator patterns. Each method offers its unique approach, suitable for specific scenarios.
Next, we will create a trivial money converter with a hardcoded exchange rate from EUR to USD to apply monkey patching using different approaches.
public interface MoneyConverter {
double convertEURtoUSD(double amount);
}
public class MoneyConverterImpl implements MoneyConverter {
private final double conversionRate;
public MoneyConverterImpl() {
this.conversionRate = 1.10;
}
@Override
public double convertEURtoUSD(double amount) {
return amount * conversionRate;
}
}
3. Dynamic Proxies
In Java, the use of proxies is a powerful technique for implementing monkey patching. A proxy is a wrapper that passes method invocation through its own facilities. This provides us with an opportunity to modify or enhance the behavior of the original class.
Notably, dynamic proxies stand as a fundamental proxy mechanism in Java. Moreover, they are widely used by frameworks like Spring Framework.
A good example is the @Transactional annotation. When applied to a method, the associated class undergoes dynamic proxy wrapping at runtime. Upon invoking the method, Spring redirects the call to the proxy. After that, the proxy initiates a new transaction or joins the existing one. Subsequently, the actual method is called. Note that, to be able to benefit from this transactional behavior, we need to rely on Spring’s dependency injection mechanism because it’s based on dynamic proxies.
Let’s use dynamic proxies to wrap our conversion method with some logs for our money converter. First, we must create a subtype of java.lang.reflect.InvocationHandler:
public class LoggingInvocationHandler implements InvocationHandler {
private final Object target;
public LoggingInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Before method: " + method.getName());
Object result = method.invoke(target, args);
System.out.println("After method: " + method.getName());
return result;
}
}
Next, let’s create a test to verify if logs surrounded the conversion method:
@Test
public void whenMethodCalled_thenSurroundedByLogs() {
ByteArrayOutputStream logOutputStream = new ByteArrayOutputStream();
System.setOut(new PrintStream(logOutputStream));
MoneyConverter moneyConverter = new MoneyConverterImpl();
MoneyConverter proxy = (MoneyConverter) Proxy.newProxyInstance(
MoneyConverter.class.getClassLoader(),
new Class[]{MoneyConverter.class},
new LoggingInvocationHandler(moneyConverter)
);
double result = proxy.convertEURtoUSD(10);
Assertions.assertEquals(11, result);
String logOutput = logOutputStream.toString();
assertTrue(logOutput.contains("Before method: convertEURtoUSD"));
assertTrue(logOutput.contains("After method: convertEURtoUSD"));
}
4. Aspect-Oriented Programming
Aspect-oriented programming (AOP) is a paradigm that addresses the cross-cutting concerns in software development, offering a modular and cohesive approach to separate concerns that would otherwise be scattered throughout the codebase. This is achieved by adding additional behavior to existing code, without modifying the code itself.
In Java, we can leverage AOP through frameworks like AspectJ or Spring AOP. While Spring AOP provides a lightweight and Spring-integrated approach, AspectJ offers a more powerful and standalone solution.
In monkey patching, AOP provides an elegant solution by allowing us to apply changes to multiple classes or methods in a centralized manner. Using aspects, we can address concerns like logging or security policies that need to be applied consistently across various components without altering the core logic.
Let’s try to surround the same method with the same logs. To do so, we will use the AspectJ framework, and we need to add the spring-boot-starter-aop dependency to our project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>3.2.2</version>
</dependency>
We can find the latest version of the library on Maven Central.
In Spring AOP, aspects are typically applied to Spring-managed beans. Therefore, we will define our money converter as a bean for simplicity:
@Bean
public MoneyConverter moneyConverter() {
return new MoneyConverterImpl();
}
Now, we need to define our aspect to surround our conversion method with logs:
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.baeldung.monkey.patching.converter.MoneyConverter.convertEURtoUSD(..))")
public void beforeConvertEURtoUSD(JoinPoint joinPoint) {
System.out.println("Before method: " + joinPoint.getSignature().getName());
}
@After("execution(* com.baeldung.monkey.patching.converter.MoneyConverter.convertEURtoUSD(..))")
public void afterConvertEURtoUSD(JoinPoint joinPoint) {
System.out.println("After method: " + joinPoint.getSignature().getName());
}
}
Next, we can create a test to verify if our aspect is applied correctly:
@Test
public void whenMethodCalled_thenSurroundedByLogs() {
ByteArrayOutputStream logOutputStream = new ByteArrayOutputStream();
System.setOut(new PrintStream(logOutputStream));
double result = moneyConverter.convertEURtoUSD(10);
Assertions.assertEquals(11, result);
String logOutput = logOutputStream.toString();
assertTrue(logOutput.contains("Before method: convertEURtoUSD"));
assertTrue(logOutput.contains("After method: convertEURtoUSD"));
}
5. Decorator Pattern
Decorator is a design pattern that allows us to attach behavior to objects by placing them inside wrapper objects. Therefore, we can assume that a decorator provides an enhanced interface to the original object.
In the context of monkey patching, it provides a flexible solution for enhancing or modifying the behavior of classes without directly modifying their code. We can create decorator classes that implement the same interfaces as the original classes and introduce additional functionality by wrapping instances of the base classes.
This pattern is particularly useful when dealing with a set of related classes that share common interfaces. By employing the Decorator Pattern, modifications can be applied selectively, allowing for a modular and non-intrusive way to adapt or extend the functionality of individual objects.
The Decorator Pattern contrasts with other monkey patching techniques, offering a more structured and explicit approach to augmenting object behavior. Its versatility makes it well-suited for scenarios where a clear separation of concerns and a modular approach to code modification are desired.
To implement this pattern, we will create a new class that will implement the MoneyConverter interface. It will have a property of type MoneyConverter, which will process the request. Moreover, the purpose of our decorator is just to add some logs and forward the money conversion request.
public class MoneyConverterDecorator implements MoneyConverter {
private final MoneyConverter moneyConverter;
public MoneyConverterDecorator(MoneyConverter moneyConverter) {
this.moneyConverter = moneyConverter;
}
@Override
public double convertEURtoUSD(double amount) {
System.out.println("Before method: convertEURtoUSD");
double result = moneyConverter.convertEURtoUSD(amount);
System.out.println("After method: convertEURtoUSD");
return result;
}
}
Now, let’s create a test to check if the logs were added:
@Test
public void whenMethodCalled_thenSurroundedByLogs() {
ByteArrayOutputStream logOutputStream = new ByteArrayOutputStream();
System.setOut(new PrintStream(logOutputStream));
MoneyConverter moneyConverter = new MoneyConverterDecorator(new MoneyConverterImpl());
double result = moneyConverter.convertEURtoUSD(10);
Assertions.assertEquals(11, result);
String logOutput = logOutputStream.toString();
assertTrue(logOutput.contains("Before method: convertEURtoUSD"));
assertTrue(logOutput.contains("After method: convertEURtoUSD"));
}
6. Reflection
Reflection is the ability of a program to examine and modify its behavior at runtime. In Java, we can use it with the help of the java.lang.reflect package or the Reflections library. While it provides significant flexibility, it should be used carefully due to its potential impact on code maintainability and performance.
One common application of reflection for monkey patching involves accessing class metadata, inspecting fields and methods, and even invoking methods at runtime. Therefore, this capability opens the door to making runtime modifications without directly altering the source code.
Let’s suppose that the conversion rate was updated to a new value. We can’t change it because we didn’t create setters for our converter class, and it is hardcoded. Therefore, we can use reflection to break encapsulation and update the conversion rate to the new value:
@Test
public void givenPrivateField_whenUsingReflection_thenBehaviorCanBeChanged() throws IllegalAccessException, NoSuchFieldException {
MoneyConverter moneyConvertor = new MoneyConverterImpl();
Field conversionRate = MoneyConverterImpl.class.getDeclaredField("conversionRate");
conversionRate.setAccessible(true);
conversionRate.set(moneyConvertor, 1.2);
double result = moneyConvertor.convertEURtoUSD(10);
assertEquals(12, result);
}
7. Bytecode Instrumentation
Through bytecode instrumentation, we can dynamically modify the bytecode of compiled classes. One popular framework for bytecode instrumentation is the Java Instrumentation API. This API was introduced with the purpose of collecting data for utilization by various tools. As these modifications are exclusively additive, such tools don’t alter the application’s state or behavior. For example, such tools include monitoring agents, profilers, coverage analyzers, and event loggers.
However, this approach introduces a more advanced level of complexity, and it’s crucial to handle it with care due to its potential impact on the runtime behavior of our application.
8. Use Cases of Monkey Patching
Monkey patching finds utility in various scenarios where making runtime modifications to code becomes a pragmatic solution. One common use case is fixing urgent bugs in third-party libraries or frameworks without waiting for official updates. It enables us to swiftly address some issues by patching the code temporarily.
Another scenario is extending or modifying the behavior of existing classes or methods in situations where direct code alterations are challenging or impractical. Also, in testing environments, monkey patching proves beneficial for introducing mock behaviors or altering functionalities temporarily to simulate different scenarios.
Furthermore, monkey patching can be employed when rapid prototyping or experimentation is required. This allows us to iterate quickly and explore various implementations without committing to permanent changes.
9. Risks of Monkey Patching
Despite its utility, monkey patching introduces some risks that should be carefully considered. Potential side effects and conflicts represent one significant risk, as modifications made at runtime might interact unpredictably. Moreover, this lack of predictability can lead to challenging debugging scenarios and increased maintenance overhead.
Furthermore, monkey patching can compromise code readability and maintainability. Injecting changes dynamically may obscure the actual behavior of the code, making it challenging for us to understand and maintain, especially in large projects.
Security concerns may also arise with monkey patching, as it can introduce vulnerabilities or malicious behavior. Additionally, the reliance on monkey patching may discourage the adoption of standard coding practices and systematic solutions to problems, leading to a less robust and cohesive codebase.
10. Conclusion
In this article, we learned that monkey patching may prove helpful and powerful in some scenarios. It can also be achieved through various techniques, each with its benefits and drawbacks. However, this approach should be used carefully because it can lead to performance, readability, maintainability, and security issues.
As always, the source code is available over on GitHub.