AspectJ、Javassist和Java代理的代码注入实用介绍


无论是静态还是运行时,将代码片段注入编译类和方法的能力可能会有很大帮助。这尤其适用于在没有源代码的第三方库中或在无法使用调试器或分析器的环境中解决问题。代码注入对于处理贯穿整个应用程序的问题也很有用,例如性能监控。以这种方式使用代码注入在这个名字下变得很流行Aspect-Oriented Programming(AOP)。代码注入并不像你想象的那样很少使用,恰恰相反;每个程序员都会遇到这种情况,这种能力可以避免很多痛苦和挫折。

这篇文章旨在给你提供你可能(或者我应该说“将”)需要的知识,并说服你学习代码注入的基础知识真的值得你花这么少的时间。我将展示三个不同的真实世界案例,其中代码注入拯救了我,用不同的工具解决每个案例,最适合手边的约束。

为什么你会需要它

关于这个问题已经说了很多advantages of AOP–因此,代码注入–因此,从故障排除的角度来看,我将只关注几个要点。

最酷的是,它使您能够修改第三方的、封闭源码的类,甚至实际上是JVM类。我们大多数人都和legacy code以及我们没有源代码的代码,不可避免地,我们偶尔会遇到这些第三方二进制文件的限制或缺陷,非常需要改变其中的一些小东西,或者对代码的行为有更多的了解。如果没有代码注入,你就没有办法修改代码或者增加对增加可观察性的支持。此外,您还经常需要在生产环境中处理问题或收集信息,在生产环境中,您不能使用调试器和类似的工具,但通常至少可以管理应用程序的二进制文件和依赖关系。考虑以下情况:

  • 您正在将一组数据传递给一个封闭的源库进行处理,该库中的一个方法对其中一个元素失败,但是异常没有提供关于它是哪个元素的信息。您需要对其进行修改,以记录有问题的参数,或者将其包含在异常中。(您不能使用调试器,因为它只发生在生产应用程序服务器上。(
  • 您需要收集应用程序中重要方法的性能统计数据,包括典型生产负载下的一些闭源组件。(在生产中,您当然不能使用探查器,您希望产生最小的开销。(
  • 您使用JDBC成批地向数据库发送大量数据,其中一次成批更新失败。您需要一些好的方法来找出它是哪一批以及它包含什么数据。

事实上,我已经遇到了这三种情况(以及其他情况),您将在后面看到可能的实现。

阅读这篇文章时,您应该记住代码注入的以下优点:

  • 代码注入使您能够修改没有源代码的二进制类
  • 注入的代码可用于在无法使用传统开发工具(如分析器和调试器)的环境中收集各种运行时信息
  • 不要重复你自己:当你在多个地方需要同一条逻辑时,你可以定义它一次,然后把它注入所有这些地方。
  • 通过代码注入,您不需要修改原始的源文件,因此它非常适合于(可能是大规模的)您只需要在有限的时间内进行的更改,特别是可以轻松地打开和关闭代码注入的工具(比如带有加载时编织的AspectJ)。一个典型的例子是在故障排除过程中收集性能指标并增加日志记录
  • 您可以在构建时静态地注入代码,也可以在JVM加载目标类时动态地注入代码

迷你词汇表

在代码注入和面向对象编程方面,您可能会遇到以下术语:

建议
要注入的代码。通常我们在建议之前、之后和周围讨论,建议在目标方法之前、之后或代替目标方法执行。除了向方法中注入代码之外,还可以进行其他更改,例如向类中添加字段或接口。
面向方面编程
一个声称“交叉关注点”——许多地方需要的逻辑,没有一个类来实现它们——应该被实现一次并注入这些地方的编程范例。支票Wikipedia为了更好的描述。
方面
面向对象编程中的模块化单元,大致对应于一个类——它可以包含不同的建议和切入点。
连接点
程序中可能成为代码注入目标的特定点,例如方法调用或方法入口。
切入点
粗略地说,切入点是一个表达式,它告诉代码注入工具在哪里注入一段特定的代码,即应用特定建议的连接点。它可以只选择一个这样的点——例如执行一个方法——或者选择许多相似的点——例如执行所有标记有自定义注释的方法,例如@MyBusinessMethod。
编织
将代码-建议-注入目标位置-连接点的过程。

工具

有许多非常不同的工具可以完成这项工作,所以我们将首先看看它们之间的区别,然后我们将熟悉代码注入工具的不同演化分支的三个杰出代表。

代码注入工具的基本分类

一、抽象层次

表达要注入的逻辑和表达逻辑应该插入的切入点有多难?

关于“建议”代码:

  1. 直接字节码操作(例如ASM)——要使用这些工具,你需要理解一个类的字节码格式,因为它们很少从中抽象出来,你直接使用操作码、操作数堆栈和单独的指令。一个美国机械工程师协会的例子:
    methodVisitor.visitFieldInsn(操作码。GETSTATIC,“java/lang/System”,“out”,“Ljava/io/PrintStream”);

    它们很难使用,因为级别太低,但却是最强大的。通常它们被用来实现更高级别的工具,实际上只有少数人需要使用它们。

  2. 中级——字符串形式的代码,类文件结构的抽象(Javassist)
  3. Java中的建议(例如AspectJ)——要注入的代码被表示为经过语法检查和静态编译的Java

关于在哪里注入代码的规范:

  1. 手动注入——你必须找到你想要注入代码的地方
  2. 原始切入点——在表达将代码注入到哪里时,你的可能性相当有限,例如,注入到一个特定的方法、一个类的所有公共方法或者一个组中的类的所有公共方法(Java EE拦截器)
  3. 模式匹配切入点表达式——基于带有通配符的多个标准、对上下文的感知(例如,“从包XY中的一个类调用”)等来匹配连接点的强大表达式。(AspectJ)

二.当奇迹发生时

代码可以在不同的时间点注入:

  • 在运行时手动——您的代码必须明确要求增强的代码,例如通过手动实例化一个包装目标对象的自定义代理(这可能不是真正的代码注入)
  • 在加载时——修改是在目标类被JVM加载时执行的
  • 在构建时——在打包和部署应用程序之前,在构建过程中添加一个额外的步骤来修改已编译的类

这些注射模式中的每一种都更适合不同的情况。

三.它能做什么

代码注入工具在它们能做什么和不能做什么方面差别很大,一些可能性是:

  • 在方法之前/之后/代替方法添加代码-仅成员级方法还是静态方法?
  • 向类中添加字段
  • 添加新方法
  • 创建一个类来实现接口
  • 修改方法体中的指令(如方法调用)
  • 修改泛型,注释,访问修饰符,改变常量值,…
  • 移除方法、字段等。

选定的代码注入工具

最著名的代码注入工具有:

  1. 动态Java代理
  2. 字节码操作库ASM
  3. JBoss Javassist
  4. AspectJ
  5. spring AOP/代理
  6. Java EE拦截器

Java代理、Javassist和AspectJ实用介绍

我选择了三种相当不同的成熟和流行的代码注入工具,并将在我亲身经历的真实世界的例子中展示它们。

无所不在的动态Java代理

Java.lang.reflect.Proxy使动态创建接口代理成为可能,将所有调用转发到目标对象。它不是一个代码注入工具,因为您不能在任何地方注入它,您必须手动实例化并使用代理而不是原始对象,并且您只能对接口这样做,但是它仍然非常有用,正如我们将看到的。

优点:

  • 它是JVM的一部分,因此随处可见
  • 您可以使用同一个代理,更确切地说是一个InvocationHandler–用于不兼容的对象,从而比正常情况下更多地重用代码
  • 您节省了精力,因为您可以轻松地将所有调用转发到目标对象,并且只修改您感兴趣的调用。如果您要手动实现一个代理,您将需要实现所讨论的接口的所有方法

缺点

  • 您只能为接口创建动态代理,如果您的代码需要一个具体的类,则不能使用它
  • 你必须手动实例化和应用它,没有神奇的自动注射
  • 这有点太冗长了
  • 它的能力非常有限,只能在一个方法之前/之后/周围执行一些代码

没有代码注入步骤——您必须手动应用代理。

例子

我正在使用JDBC准备状态的批量更新来修改数据库中的大量数据,其中一个批量更新的处理因违反完整性约束而失败。该异常没有包含足够的信息来找出导致失败的数据,因此我为准备状态创建了一个动态代理,它会记住传递到每个批处理更新中的值,并且在失败的情况下,它会自动打印批号和数据。有了这些信息,我就可以修复数据,并保留解决方案,这样,如果类似的问题再次发生,我就可以找到原因并快速解决它。

代码的关键部分是:

LoggingStatementDecorator.java–片段1
class LoggingStatementDecorator implements InvocationHandler {

   private PreparedStatement target;
   ...

   private LoggingStatementDecorator(PreparedStatement target) { this.target = target; }

   @Override
   public Object invoke(Object proxy, Method method, Object[] args)
         throws Throwable {

      try {
         Object result = method.invoke(target, args);
         updateLog(method, args); // remember data, reset upon successful execution
         return result;
      } catch (InvocationTargetException e) {
         Throwable cause = e.getTargetException();
         tryLogFailure(cause);
         throw cause;
      }

   }

   private void tryLogFailure(Throwable cause) {
      if (cause instanceof BatchUpdateException) {
         int failedBatchNr = successfulBatchCounter + 1;
         Logger.getLogger("JavaProxy").warning(
               "THE INJECTED CODE SAYS: " +
               "Batch update failed for batch# " + failedBatchNr +
               " (counting from 1) with values: [" +
               getValuesAsCsv() + "]. Cause: " + cause.getMessage());
      }
   }
...

注释:

  • 要创建代理,首先需要实现一个InvocationHandler以及它的invoke方法,每当在代理上调用接口的任何方法时都会调用该方法
  • 您可以通过java.lang.reflect.*对象访问有关调用的信息,例如,通过方法。调用

我们还有一个实用方法来为一个准备好的语句创建一个代理实例:

LoggingStatementDecorator.java–片段2
public static PreparedStatement createProxy(PreparedStatement target) {
  return (PreparedStatement) Proxy.newProxyInstance(
      PreparedStatement.class.getClassLoader(),
      new Class[] { PreparedStatement.class },
      new LoggingStatementDecorator(target));
};

注释:

  • 你可以看到新代理实例call使用一个类加载器,一个代理应该实现的接口数组,以及调用应该委托给的调用处理程序(如果需要,处理程序本身必须管理对代理对象的引用)

它是这样使用的:

Main.java
...
PreparedStatement rawPrepStmt = connection.prepareStatement("...");
PreparedStatement loggingPrepStmt = LoggingStatementDecorator.createProxy(rawPrepStmt);
...
loggingPrepStmt.executeBatch();
...

注释:

  • 您会看到,我们必须用代理手动包装一个原始对象,然后继续使用代理
替代解决方案

这个问题可以用不同的方法来解决,例如通过创建一个非动态代理来实现PreparedStatement,并在记住批处理数据的同时将所有调用转发到真实的语句,但是这将是非常无聊的键入,因为接口有很多方法。调用者也可以手动跟踪它发送给准备好的语句的数据,但是这样会用一个不相关的问题模糊它的逻辑。

使用动态的Java代理,我们得到了一个相当干净和容易实现的解决方案。

独立爪哇人

JBoss Javassist是一个中间代码注入工具,它提供了比字节码操作库更高级别的抽象,并且提供了很少但仍然非常有用的操作能力。要注入的代码表示为字符串,您必须手动到达类方法来注入它。它的主要优点是修改后的代码没有新的运行时依赖性,不依赖于Javassist或其他任何东西。如果你在一家大公司工作,这可能是决定性的因素,在那里,由于法律和其他原因,像AspectJ这样的额外的开源库(或几乎任何额外的库)的部署是困难的。

优势

  • Javassist修改的代码不需要任何新的运行时依赖,注入发生在构建时,注入的建议代码本身不依赖于任何Javassist应用编程接口
  • 比字节码操作库更高级的是,注入的代码是用Java语法编写的,尽管用字符串表示
  • 可以做您可能需要的大多数事情,例如“建议”方法调用和方法执行

缺点

  • 仍然有点太低级,因此更难使用——你必须处理一些方法的结构,并且注入的代码没有经过语法检查
  • 注入是手动完成的,不支持基于模式自动注入代码(尽管我曾经实现了一个custom Ant task to do execution/call advising用于Javassist)
  • 仅构建时注入

(关于没有Javassist大部分缺点的解决方案,请参见下面的GluonJ)。(

使用Javassist,您可以创建一个类,该类使用Javassist API将代码注入到目标中,并在编译后作为构建过程的一部分运行它,例如,我曾经通过一个定制的Ant任务这样做过。

例子

我们需要在我们的Java EE应用程序中添加一些简单的性能监控,并且不允许我们部署任何未经批准的开源库(至少不经过耗时的批准过程)。因此,我们使用Javassist将性能监控代码注入到我们重要的方法中,以及调用重要外部方法的地方。

代码注入器:

JavassistInstrumenter.java
public class JavassistInstrumenter {

   public void insertTimingIntoMethod(String targetClass, String targetMethod) throws NotFoundException, CannotCompileException, IOException {
      Logger logger = Logger.getLogger("Javassist");
      final String targetFolder = "./target/javassist";

      try {
         final ClassPool pool = ClassPool.getDefault();
         // Tell Javassist where to look for classes - into our ClassLoader
         pool.appendClassPath(new LoaderClassPath(getClass().getClassLoader()));
         final CtClass compiledClass = pool.get(targetClass);
         final CtMethod method = compiledClass.getDeclaredMethod(targetMethod);

         // Add something to the beginning of the method:
         method.addLocalVariable("startMs", CtClass.longType);
         method.insertBefore("startMs = System.currentTimeMillis();");
         // And also to its very end:
         method.insertAfter("{final long endMs = System.currentTimeMillis();" +
            "iterate.jz2011.codeinjection.javassist.PerformanceMonitor.logPerformance(\"" +
            targetMethod + "\",(endMs-startMs));}");

         compiledClass.writeFile(targetFolder);
         // Enjoy the new $targetFolder/iterate/jz2011/codeinjection/javassist/TargetClass.class

         logger.info(targetClass + "." + targetMethod +
               " has been modified and saved under " + targetFolder);
      } catch (NotFoundException e) {
         logger.warning("Failed to find the target class to modify, " +
               targetClass + ", verify that it ClassPool has been configured to look " +
               "into the right location");
      }
   }

   public static void main(String[] args) throws Exception {
      final String defaultTargetClass = "iterate.jz2011.codeinjection.javassist.TargetClass";
      final String defaultTargetMethod = "myMethod";
      final boolean targetProvided = args.length == 2;

      new JavassistInstrumenter().insertTimingIntoMethod(
            targetProvided? args[0] : defaultTargetClass
            , targetProvided? args[1] : defaultTargetMethod
      );
   }
}

注释:

  • 你可以看到“低水平”——你必须显式地处理像CtClass、CtMethod这样的对象,显式地添加一个局部变量等等。
  • Javassist在寻找要修改的类方面相当灵活——它可以搜索类路径、特定的文件夹、JAR文件或包含JAR文件的文件夹
  • 您可以编译这个类,并在构建过程中运行它的main

类固醇上的javassist:GluonJ

GluonJ是一个建立在Javassist之上的AOP工具。它可以使用自定义语法或Java 5注释,并且是围绕“修订者”的概念构建的。修正者是一个类——一个方面——它修正,即修改一个特定的目标类,并覆盖它的一个或多个方法(与继承相反,修正者的代码实际上是强加在目标类内的原始代码之上)。

优势

  • 如果使用构建时编织,则没有运行时依赖关系(加载时编织需要GluonJ代理库或gluonj.jar)
  • 简单的Java语法使用了GlutonJ的注释——尽管自定义语法也很容易理解和使用
  • 使用GlutonJ的JAR工具,一个蚂蚁任务,或者在加载时动态地,容易地自动编织到目标类中
  • 支持构建时和加载时编织

缺点

  • 一个方面只能修改一个类,不能将同一段代码注入多个类/方法
  • 有限能力–仅提供字段/方法添加和代码执行,而不是/围绕目标方法,无论是在其任何执行时,还是仅当执行发生在特定上下文中时,即当从特定类/方法调用时

如果您不需要将同一段代码注入到多个方法中,那么GluonJ比Javassist更容易,也是更好的选择,如果它的简单性对您来说不是问题,那么它也可能是比AspectJ更好的选择,仅仅是因为它的简单性。

全能的AspectJ

AspectJ是一个成熟的AOP工具,它几乎可以做任何你想做的事情,包括修改静态方法,添加新的字段,将一个接口添加到类的已实现接口列表中等等。

AspectJ建议的语法有两种风格,一种是带有附加关键字的Java语法的超集,例如方面切入点另一个叫做@AspectJ,是标准的Java 5,带有诸如@Aspect、@切入点、@ Outh这样的注释。后者可能更容易学习和使用,但也没那么强大,因为它不像自定义AspectJ语法那样有表现力。

使用AspectJ,您可以用非常强大的表达式来定义建议哪些连接点,但是学习它们并使它们正确可能并不困难。对于AspectJ开发,有一个有用的Eclipse插件AspectJ Development Tools(AJDT)——但是我最后一次尝试的时候,它并没有我所希望的那么有帮助。

优势

  • 非常强大,几乎可以做任何你可能需要的事情
  • 强大的切入点表达式,用于定义在何处注入建议以及何时激活建议(包括一些运行时检查)——完全启用干燥的,即写一次&注入多次
  • 构建时和加载时代码注入(编织)

缺点

  • 修改后的代码依赖于AspectJ运行时库
  • 切入点表达式非常强大,但是可能很难把它们弄对,而且没有太多的支持来“调试”它们,尽管AJDT插件能够部分地可视化它们的效果
  • 虽然基本用法非常简单(使用@Aspect、@ about和一个简单的切入点表达式,如我们将在示例中看到的),但可能需要一些时间才能开始
例子

很久以前,我在为一个封闭源码写插件LMSJ2EE应用程序具有如此大的依赖性,以至于在本地运行是不可行的。在一次应用编程接口调用中,应用程序内部的一个方法失败了,但是异常没有包含足够的信息来跟踪问题的原因。因此,我需要更改方法,以便在失败时记录其参数的值。

AspectJ代码非常简单:

LoggingAspect.java
@Aspect
public class LoggingAspect {

   @Around("execution(private void TooQuiet3rdPartyClass.failingMethod(..))")
   public Object interceptAndLog(ProceedingJoinPoint invocation) throws Throwable {
      try {
         return invocation.proceed();
      } catch (Exception e) {
         Logger.getLogger("AspectJ").warning(
            "THE INJECTED CODE SAYS: the method " +
            invocation.getSignature().getName() + " failed for the input '" +
            invocation.getArgs()[0] + "'. Original exception: " + e);
         throw e;
      }
   }
}

注释:

由于我无法控制应用程序的环境,我无法启用加载时编织,因此不得不使用AspectJ’s Ant task为了在构建时编织代码,重新打包受影响的JAR,并将其重新部署到服务器。

替代解决方案

如果你不能使用调试器,那么你的选择是非常有限的。我能想到的唯一替代方案是decompile该类(非法!),将日志添加到方法中(前提是反编译成功),重新编译它并替换原来的。用修改后的类初始化。

黑暗面

代码注入和面向方面编程非常强大,有时对于故障排除和作为应用程序体系结构的常规部分都是不可或缺的,正如我们可以看到的,例如在Java EE的企业Java Beans中,诸如事务管理和安全检查之类的业务关注被注入到POJOs中(尽管实现实际上更可能使用代理),或者在Spring中。

然而,这是要付出代价的也许可理解性降低,因为运行时行为和结构与基于源代码的预期不同(除非您知道也要检查方面的来源,或者除非注入是通过对目标类(如Java EE)的注释来明确的)@Interceptors)。因此,你必须仔细权衡代码注入/面向对象编程的优缺点——尽管当合理使用时,它们不会比接口、工厂等更模糊程序流。这argument about obscuring code is perhaps often over-estimated

如果你想看到人工臭氧层变得疯狂的例子,请查看source codesGlassbox,一个JavaEE性能监控工具(为此您可能需要一个map不要太迷路)。

代码注入和面向对象编程的奇特用法

在故障排除过程中,代码注入应用程序的主要领域是日志记录,通过提取和以某种方式传递关于应用程序的有趣运行时信息,更准确地了解应用程序正在做什么。然而,除了简单或复杂的日志记录之外,AOP还有许多有趣的用途,例如:

摘要

我们已经了解到,代码注入对于故障排除是不可或缺的,尤其是在处理闭源库和复杂的部署环境时。我们已经看到三种不同的代码注入工具——动态Java代理、Javassist和AspectJ——应用于现实问题,并讨论了它们的优缺点,因为不同的工具可能适用于不同的情况。我们还提到了代码注入/面向对象编程不应该被过度使用,并看了一些代码注入/面向对象编程高级应用的例子。

我希望您现在了解代码注入如何帮助您,并知道如何使用这三种工具。

源代码

你可以get the fully-documented source codes of the examples从GitHub中,不仅包括要注入的代码,还包括目标代码和对简单构建的支持。最简单的可能是:

git clone git://github.com/jakubholynet/JavaZone-Code-Injection.git
cd JavaZone-Code-Injection/
cat README
mvn -P javaproxy test
mvn -P javassist test
mvn -P aspectj   test

(Maven下载它的依赖项、插件和实际项目的依赖项可能需要几分钟时间。(

额外资源

承认

我要感谢所有帮助我完成这篇文章和演讲的人,包括我所在的大学、JRebel人和GluonJ的合著者千叶繁教授。

 

发件人http://theholyjava.wordpress.com/2011/09/07/practical-introduction-into-code-injection-with-aspectj-javassist-and-java-proxy/