Java 中的 Monkey 补丁模式


在这篇文章中描述了 Java 中Monkey修补的几种方法:类Proxy、通过 Java 代理进行检测、通过 AspectJ 进行 AOP 以及javac编译器插件。

要选择其中一种,请考虑以下标准:构建时与运行时、复杂性、本机与第三方以及安全问题。
这篇文章的完整源代码可以在Github上找到

JVM 是一个出色的monkey patching平台。

Java代理
Java 代理是一个通用装饰器,允许附加动态行为:

Proxy 提供静态方法来创建对象,这些对象的行为类似于接口实例,但允许自定义方法调用。

Spring 框架大量使用 Java 代理。如果您注释一个方法,Spring 会在运行时围绕封装类创建一个 Java 代理。当您调用它时,Spring 会调用代理。根据配置,它打开事务或加入现有事务,然后调用实际方法,最后提交(或回滚)。

我们可以编写以下处理程序:

public class RepeatingInvocationHandler implements InvocationHandler {

    private final Logger logger;                                       
    private final int times;                                           

    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) { 
            for (int i = 0; i < times; i++) {
                method.invoke(logger, args[0]);                       
            }
        }
        return null;
    }
}

创建代理的方法如下:

var logger = new ConsoleLogger();
var proxy = (Logger) Proxy.newProxyInstance(            
        Main.class.getClassLoader(),
        new Class[]{Logger.class},                     
        new RepeatingInvocationHandler(logger, 3));    
proxy.log("Hello world!");

Instrumentation
检测Instrumentation是 JVM 在通过Java 代理加载字节码之前转换字节码的能力。有两种 Java 代理风格可用:

Instrumentation API是通过字节数组向用户公开低级字节码操作。直接做的话会很不方便。

因此,现实生活中的项目依赖于字节码操作库。ASM一直是这方面的传统库,但[url=https://bytebuddy.net/]Byte Buddy[/url]似乎已经取代了它。请注意,Byte Buddy 使用 ASM,但提供了更高级别的抽象。

Byte Buddy API 超出了本博客文章的范围,因此让我们直接深入了解代码:

public class Repeater {

  public static void premain(String arguments, Instrumentation instrumentation) {      
    var withRepeatAnnotation = isAnnotatedWith(named("ch.frankel.blog.instrumentation.Repeat")); 
    new AgentBuilder.Default()                     
//Byte Buddy 提供了一个构建器来创建 Java 代理                                
      .type(declaresMethod(withRepeatAnnotation))                                      
      .transform((builder, typeDescription, classLoader, module, domain) -> builder    
        .method(withRepeatAnnotation)                                                  
        .intercept(                                                                    
           SuperMethodCall.INSTANCE                                                    
            .andThen(SuperMethodCall.INSTANCE)
            .andThen(SuperMethodCall.INSTANCE))
      ).installOn(instrumentation);                                                    
  }
}

下一步是创建 Java 代理包。Java 代理是具有特定清单属性的常规 JAR。让我们配置 Maven 来构建代理:

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>                                      
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>                        
        </descriptorRefs>
        <archive>
            <manifestEntries>
                <Premain-Class>ch.frankel.blog.instrumentation.Repeater</Premain-Class> 
            </manifestEntries>
        </archive>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>single</goal>
            </goals>
            <phase>package</phase>                                                      
        </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 \ 
     -cp ./target/classes                                                                                                    
     ch.frankel.blog.instrumentation.Main                


面向方面的编程
AOP背后的想法是在不同的不相关的对象层次结构中应用一些代码 - 横切关注点。对于不允许特征的语言来说,这是一项有价值的技术,您可以将代码移植到第三方对象/类上。有趣的事实:我之前了解过 AOP Proxy。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>

和Byte Buddy一样,AspectJ底层也使用了ASM。

这是代码:

@Aspect                                                                              
public class RepeatingAspect {

    @Pointcut("@annotation(repeat) && call(* *(..))"//定义切入点                            
    public void callAt(Repeat repeat) {}                                             

    @Around(
"callAt(repeat)")   //需要调用的原始方法                                    
    public Object around(ProceedingJoinPoint pjp, Repeat repeat) throws Throwable {  
        for (int i = 0; i < repeat.times(); i++) {                                   
            pjp.proceed();                                                           
        }
        return null;
    }
}
此时,我们需要编织切面。让我们在构建时进行。为此,我们可以添加 AspectJ 构建插件:

pom.xml
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>                  
            </goals>
        </execution>
    </executions>
</plugin>

查看演示效果:

mvn compile exec:java -Dexec.mainClass=ch.frankel.blog.aop.Main


Java编译器插件
最后,可以通过 Java 编译器插件更改生成的字节码,该插件在 Java 6 中作为JSR 269引入。从鸟瞰角度来看,插件涉及挂钩 Java 编译器以分三个阶段操作AST:将源代码解析为多个 AST,进一步分析Element,并可能生成源代码。

如果您有兴趣,我相信DocLint 源代码是一个很好的起点。