当前位置:Java -> Java中的Monkey-Patching
JVM 是一个非常出色的平台,非常适合进行 Monkey-Patching。
Monkey-Patching 是一种在运行时动态更新代码行为的技术。Monkey patch(也可拼写为 monkey-patch、MonkeyPatch)是一种可以扩展或修改动态语言(例如 Smalltalk、JavaScript、Objective-C、Ruby、Perl、Python、Groovy 等)运行时代码的方式,而不会改变原始源代码。
— 维基百科
我想在这篇文章中演示几种在 Java 中进行 Monkey-Patching 的方法。
举个例子,我们使用一个简单的 for 循环。假设我们有一个类和一个方法,我们希望能够在不显式调用方法的情况下多次调用该方法。
虽然装饰器设计模式不是 Monkey-Patching,但它仍然是一个很好的介绍。装饰器是一个在基础书籍《设计模式:可复用面向对象软件的元素》中描述的一种 结构型 模式。
装饰器模式是一种设计模式,允许动态地向单个对象添加行为,而不会影响同一类的其他对象的行为。
— 装饰器模式
我们的用例是一个 Logger
接口,具有专用的控制台实现:
我们可以在 Java 中这样实现它:
public interface Logger {
void log(String message);
}
public class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println(message);
}
}
这是一个简单的可配置的装饰器实现:
public class RepeatingDecorator implements Logger { //1
private final Logger logger; //2
private final int times; //3
public RepeatingDecorator(Logger logger, int times) {
this.logger = logger;
this.times = times;
}
@Override
public void log(String message) {
for (int i = 0; i < times; i++) { //4
logger.log(message);
}
}
}
使用装饰器非常简单:
var logger = new ConsoleLogger();
var threeTimesLogger = new RepeatingDecorator(logger, 3);
threeTimesLogger.log("Hello world!");
Java 代理是一种通用的装饰器,允许附加动态行为:
代理提供了用于创建像接口实例一样的对象的静态方法,同时允许自定义方法调用。
Spring 框架大量使用 Java 代理。这就是 @Transactional
注解的情况。如果你对一个方法进行注解,Spring 会在运行时创建一个 Java 代理。当你调用它时,Spring 会调用代理。根据配置,它会打开事务或加入现有事务,然后调用实际方法,最后提交(或回滚)事务。
API 很简单:
我们可以编写以下处理程序:
public class RepeatingInvocationHandler implements InvocationHandler {
private final Logger logger; //1
private final int times; //2
public RepeatingInvocationHandler(Logger logger, int times) {
this.logger = logger;
this.times = times;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
if (method.getName().equals("log") && args.length ## 1 && args[0] instanceof String) { //3
for (int i = 0; i < times; i++) {
method.invoke(logger, args[0]); //4
}
}
return null;
}
}
以下是创建代理的方法:
var logger = new ConsoleLogger();
var proxy = (Logger) Proxy.newProxyInstance( //1-2
Main.class.getClassLoader(),
new Class[]{Logger.class}, //3
new RepeatingInvocationHandler(logger, 3)); //4
proxy.log("Hello world!");
Proxy
对象Logger
,它返回一个 Object
Instrumentation 是 JVM 在加载字节码之前转换字节码的能力,可以通过 Java 代理 来实现。有两种 Java 代理的类型:
Instrumentation API 的表面面相对有限:
如上所述,API通过字节数组将用户暴露给低级字节码操作。直接进行操作可能会很麻烦,因此现实项目依赖于字节码操作库。ASM一直是传统的库,但似乎Byte Buddy已经取代了它。请注意,Byte Buddy使用ASM,但提供了更高层次的抽象。
Byte Buddy API超出了本博文的范围,所以让我们直接进入代码:
public class Repeater {
public static void premain(String arguments, Instrumentation instrumentation) { //1
var withRepeatAnnotation = isAnnotatedWith(named("ch.frankel.blog.instrumentation.Repeat")); //2
new AgentBuilder.Default() //3
.type(declaresMethod(withRepeatAnnotation)) //4
.transform((builder, typeDescription, classLoader, module, domain) -> builder //5
.method(withRepeatAnnotation) //6
.intercept( //7
SuperMethodCall.INSTANCE //8
.andThen(SuperMethodCall.INSTANCE)
.andThen(SuperMethodCall.INSTANCE))
).installOn(instrumentation); //3
}
}
main
方法相似,但增加了Instrumentation
参数@Repeat
注解的匹配。即使你不了解它(我也不了解),这种DSL读起来也很流畅。@Repeat
注解的方法的所有类型@Repeat
注解的方法下一步是创建Java代理包。Java代理是具有特定清单属性的常规JAR。让我们配置Maven来构建代理:
<plugin>
<artifactId>maven-assembly-plugin</artifactId> <!--1-->
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef> <!--2-->
</descriptorRefs>
<archive>
<manifestEntries>
<Premain-Class>ch.frankel.blog.instrumentation.Repeater</Premain-Class> <!--3-->
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<goals>
<goal>single</goal>
</goals>
<phase>package</phase> <!--4-->
</execution>
</executions>
</plugin>
测试更加复杂,因为我们需要两个不同的代码库,一个用于代理,另一个用于带有注解的常规代码。让我们先创建代理:
mvn install
然后我们可以使用代理运行应用:
java -javaagent:/Users/nico/.m2/repository/ch/frankel/blog/agent/1.0-SNAPSHOT/agent-1.0-SNAPSHOT-jar-with-dependencies.jar \ #1
-cp ./target/classes #2
ch.frankel.blog.instrumentation.Main #3
premain
方法AOP的概念是在不同的无关对象层次结构上应用一些代码 - 横切关注点。这在不允许traits的语言中是一种有价值的技术,traits是可以嵌入到第三方对象/类中的代码。有趣的是: 在了解Proxy
之前,我学会了AOP。AOP依赖于两个主要概念: 切面是应用于代码的转换,而切入点匹配切面应用的位置。
在Java中,AOP的历史实现是优秀的AspectJ库。AspectJ提供了两种方法,称为织入: 生成时织入,它转换已编译的字节码,以及运行时织入,它依赖于上述的仪器。无论哪种方式,AspectJ都使用特定格式的切面和切入点。在Java 5之前,格式看起来像是Java,但又不完全一样; 例如,它使用aspect
关键字。从Java 5开始,可以在常规Java代码中使用注解来实现相同的目标。
我们需要一个AspectJ依赖项:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.19</version>
</dependency>
@Aspect //1
public class RepeatingAspect {
@Pointcut("@annotation(repeat) && call(* *(..))") //2
public void callAt(Repeat repeat) {} //3
@Around("callAt(repeat)") //4
public Object around(ProceedingJoinPoint pjp, Repeat repeat) throws Throwable { //5
for (int i = 0; i < repeat.times(); i++) { //6
pjp.proceed(); //7
}
return null;
}
}
@Repeat
注解的方法@Repeat
注解绑定到上面注释中使用的repeat
名称@Around
,意味着我们需要显式调用原始方法ProceedingJoinPoint
,它引用原始方法,以及@Repeat
注解
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>compile</goal> <!--1-->
</goals>
</execution>
</executions>
</plugin>
compile
阶段
mvn compile exec:java -Dexec.mainClass=ch.frankel.blog.aop.Main
最后,可以通过Java编译器插件改变生成的字节码。这种插件在Java 6中作为JSR 269引入。从宏观角度看,插件涉及连接到Java编译器以操纵AST的三个阶段: 将源代码解析为多个AST,进一步分析为Element
,并且可能生成源代码。
文档可能较为简洁。我找到了以下超棒的Java注解处理。以下是一个简化的类图,让你开始:
我懒得用这样低级的API实现和上面一样的东西。正如所言,这留给读者作为练习。如果你有兴趣,我相信DocLint
源代码是一个不错的起点。
我在这篇文章中描述了在Java中使用猴子补丁的几种方法: Proxy
类、通过Java代理进行检测、通过AspectJ进行AOP、以及javac
编译器插件。要选择其中一种方法,考虑以下标准:构建时间vs.运行时间,复杂性,本地vs.第三方,以及安全性问题。
推荐阅读: 2.短网址系统
本文链接: Java中的Monkey-Patching