当前位置:Java -> Java中的Monkey-Patching

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


  1. 必须实现该接口
  2. 底层记录器
  3. 循环配置
  4. 根据需要多次调用方法

使用装饰器非常简单:

var logger = new ConsoleLogger();
var threeTimesLogger = new RepeatingDecorator(logger, 3);
threeTimesLogger.log("Hello world!");


Java 代理

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

代理提供了用于创建像接口实例一样的对象的静态方法,同时允许自定义方法调用。

代理 Javadoc

Spring 框架大量使用 Java 代理。这就是 @Transactional 注解的情况。如果你对一个方法进行注解,Spring 会在运行时创建一个 Java 代理。当你调用它时,Spring 会调用代理。根据配置,它会打开事务或加入现有事务,然后调用实际方法,最后提交(或回滚)事务。

API 很简单:

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


  1. 底层记录器
  2. 循环配置
  3. 检查每个要求是否得到满足
  4. 在底层记录器上调用初始方法

以下是创建代理的方法:

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!");


  1. 创建 Proxy 对象
  2. 由于 API 是在泛型出现之前创建的,需要将其转换为 Logger,它返回一个 Object
  3. 接口数组,对象需要符合这些接口
  4. 传递我们的处理程序

Instrumentation

Instrumentation 是 JVM 在加载字节码之前转换字节码的能力,可以通过 Java 代理 来实现。有两种 Java 代理的类型:

  • 静态,在启动应用程序时通过命令行传递代理
  • 动态,允许连接到运行中的 JVM 并通过 附加 API 在其上附加代理。需要注意的是,它代表了一个巨大的安全问题,在最新的 JDK 中已经受到严格限制。

Instrumentation API 的表面面相对有限:

java.lang.instrument

如上所述,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
  }
}


  1. 需要的签名;与main方法相似,但增加了Instrumentation参数
  2. 带有@Repeat注解的匹配。即使你不了解它(我也不了解),这种DSL读起来也很流畅。
  3. Byte Buddy提供了创建Java代理的构建器
  4. 匹配具有带有@Repeat注解的方法的所有类型
  5. 相应地转换类
  6. 转换带有@Repeat注解的方法
  7. 用以下内容替换原始实现
  8. 调用原始实现三次

下一步是创建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>


  1. 创建包含所有依赖项的JAR()

测试更加复杂,因为我们需要两个不同的代码库,一个用于代理,另一个用于带有注解的常规代码。让我们先创建代理:

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


  1. 使用在前一步中创建的代理运行Java。JVM将运行代理中配置的premain方法
  2. 配置类路径
  3. 设置主类

面向方面的编程

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


  1. 将此类标记为切面
  2. 定义切入点;每次调用带有@Repeat注解的方法
  3. @Repeat注解绑定到上面注释中使用的repeat名称
  4. 定义应用于调用站点的切面;它是一个@Around,意味着我们需要显式调用原始方法
  5. 签名使用ProceedingJoinPoint,它引用原始方法,以及@Repeat注解
  6. 循环次数与配置相匹配
  7. 调用原始方法
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>compile</goal>                  <!--1-->
            </goals>
        </execution>
    </executions>
</plugin>


  1. 将插件的执行绑定到compile阶段
mvn compile exec:java -Dexec.mainClass=ch.frankel.blog.aop.Main


Java编译器插件

最后,可以通过Java编译器插件改变生成的字节码。这种插件在Java 6中作为JSR 269引入。从宏观角度看,插件涉及连接到Java编译器以操纵AST的三个阶段: 将源代码解析为多个AST,进一步分析为Element,并且可能生成源代码。

文档可能较为简洁。我找到了以下超棒的Java注解处理。以下是一个简化的类图,让你开始:

Java编译器插件

我懒得用这样低级的API实现和上面一样的东西。正如所言,这留给读者作为练习。如果你有兴趣,我相信DocLint源代码是一个不错的起点。

结论

我在这篇文章中描述了在Java中使用猴子补丁的几种方法: Proxy类、通过Java代理进行检测、通过AspectJ进行AOP、以及javac编译器插件。要选择其中一种方法,考虑以下标准:构建时间vs.运行时间,复杂性,本地vs.第三方,以及安全性问题。

进一步了解

推荐阅读: 2.短网址系统

本文链接: Java中的Monkey-Patching