Java 编程的动态性,第 6 部分: 利用 Javassist 进行面向方面的更改

时间:2021-01-11 16:38:05

本系列的 第 4 部分和 第 5 部分讨论了如何用 Javassist 对二进制类进行局部更改。这次您将学习以一种更强大的方式使用该框架,从而充分利用 Javassist 对在字节码中查找所有特定方法或者字段的支持。对于 Javassist 功能而言,这个功能至少与它以类似源代码的方式指定字节码的能力同样重要。对选择替换操作的支持也有助于使 Javasssist 成为一个在标准 Java 代码中增加面向方面的编程功能的绝好工具。

第 5 部分介绍了 Javassist 是如何让您拦截类加载过程的 ―― 甚至在二进制类表示正在被加载的时候对它们进行更改。这篇文章中讨论的系统字节码转换可以用于静态类文件转换,也可以用于运行时拦截,但是在运行时使用尤其有用。

处理字节码修改

Javassist 提供了两种不同的系统字节码修改的处理方法。第一种技术是使用 javassist.CodeConverter 类,使用起来要稍微简单一些,但是可以完成的任务有很多限制。第二种技术使用 javassist.ExprEditor 类的自定义子类,它稍微复杂一些,但是所增加的灵活性足以抵销所付出的努力。在本文中我将分析这两种方法的例子。

代码转换

系统字节码修改的第一种 Javassist 技术使用 javassist.CodeConverter 类。要利用这种技术,只需要创建 CodeConverter 类的一个实例并用一个或者多个转换操作配置它。每一个转换都是用识别转换类型的方法调用来配置的。转换类型可分为三类:方法调用转换、字段访问转换和新对象转换。

不要错过本系列的其余文章

第 1 部分, “ 类和类装入”(2003 年 4 月)

第 2 部分, “ 引入反射”(2003 年 6 月)

第 3 部分, “ 应用反射”(2003 年 7 月)

第 4 部分, “ 用 Javassist 进行类转换”(2003 年 9 月)

第 5 部分, “ 动态转换类”(2004 年 2 月)

清单 1 给出了使用方法调用转换的一个例子。在这个例子中,转换只是增加了一个方法正在被调用的通知。在代码中,首先得到将要使用的 javassist.ClassPool 实例,将它配置为与一个翻译器一同工作 (正如在前面 第 5 部分 所看到的)。然后,通过 ClassPool 访问两个方法定义。第一个方法定义针对的是要监视的“set”类型的方法(类和方法名来自命令行参数),第二个方法定义针对的是 reportSet() 方法 ,它位于TranslateConvert 类中,并会报告对第一个方法的调用。

有了方法信息后,就可以用 CodeConverterinsertBeforeMethod() 配置一个转换,以在每次调用这个 set 方法之前增加一个对报告方法的调用。然后所要做的就是将这个转换器应用到一个或者多个类上。在清单 1 的代码中,我是通过调用类对象的 instrument() 方法,在ConverterTranslator 内部类的 onWrite() 方法中完成这项工作的。这将自动对从 ClassPool 实例中加载的每一个类应用这个转换。

清单 1. 使用 CodeConverter
public class TranslateConvert
{
    public static void main(String[] args) {
        if (args.length >= 3) {
            try {
                
                // set up class loader with translator
                ConverterTranslator xlat =
                    new ConverterTranslator();
                ClassPool pool = ClassPool.getDefault(xlat);
                CodeConverter convert = new CodeConverter();
                CtMethod smeth = pool.get(args[0]).
                    getDeclaredMethod(args[1]);
                CtMethod pmeth = pool.get("TranslateConvert").
                    getDeclaredMethod("reportSet");
                convert.insertBeforeMethod(smeth, pmeth);
                xlat.setConverter(convert);
                Loader loader = new Loader(pool);
                
                // invoke "main" method of application class
                String[] pargs = new String[args.length-3];
                System.arraycopy(args, 3, pargs, 0, pargs.length);
                loader.run(args[2], pargs);
                
            } catch ...
            }
            
        } else {
            System.out.println("Usage: TranslateConvert " +
                "clas-name set-name main-class args...");
        }
    }
    
    public static void reportSet(Bean target, String value) {
        System.out.println("Call to set value " + value);
    }
    
    public static class ConverterTranslator implements Translator
    {
        private CodeConverter m_converter;
        
        private void setConverter(CodeConverter convert) {
            m_converter = convert;
        }
        
        public void start(ClassPool pool) {}
        
        public void onWrite(ClassPool pool, String cname)
            throws NotFoundException, CannotCompileException {
            CtClass clas = pool.get(cname);
            clas.instrument(m_converter);
        }
    }
}

配置转换是一个相当复杂的操作,但是设置好以后,在它工作时就不用费什么心了。清单 2 给出了代码示例,可以作为测试案例。这里 Bean提供了具有类似 bean 的 get 和 set 方法的测试对象, BeanTest 程序用这些方法来访问值。

清单 2. 一个 bean 测试程序
public class Bean
{
    private String m_a;
    private String m_b;
    
    public Bean() {}
    
    public Bean(String a, String b) {
        m_a = a;
        m_b = b;
    }
    
    public String getA() {
        return m_a;
    }
    public String getB() {
        return m_b;
    }
    public void setA(String string) {
        m_a = string;
    }
    public void setB(String string) {
        m_b = string;
    }
}
public class BeanTest
{
    private Bean m_bean;
    
    private BeanTest() {
        m_bean = new Bean("originalA", "originalB");
    }
    
    private void print() {
        System.out.println("Bean values are " +
            m_bean.getA() + " and " + m_bean.getB());
    }
    
    private void changeValues(String lead) {
        m_bean.setA(lead + "A");
        m_bean.setB(lead + "B");
    }
    
    public static void main(String[] args) {
        BeanTest inst = new BeanTest();
        inst.print();
        inst.changeValues("new");
        inst.print();
    }
}

如果直接运行清单 2 中的 中的 BeanTest 程序,则输出如下:

[dennis]$ java -cp . BeanTest
Bean values are originalA and originalB
Bean values are newA and newB

如果用 清单 1 中的 TranslateConvert 程序运行它并指定监视其中的一个 set 方法,那么输出将如下所示:

[dennis]$ java -cp .:javassist.jar TranslateConvert Bean setA BeanTest
Bean values are originalA and originalB
Call to set value newA
Bean values are newA and newB

每项工作都与以前一样,但是现在在执行这个程序时,所选的方法被调用时会有一个通知。

在这个例子中,可以用其他的方法容易地实现同样的效果,例如通过使用 第 4 部分 中的技术在实际的 set 方法体中增加代码。这里的区别是,在使用位置增加代码让我有了灵活性。例如,可以容易地修改 TranslateConvert.ConverterTranslatoronWrite() 方法来检查正在加载的类名,并只转换在我想要监视的类的清单中列出的类。直接在 set 方法体中添加代码无法进行这种有选择的监视。

系统字节码转换由于提供了灵活性而使其成为为标准 Java 代码实现面向方面的扩展的强大工具。在本文后面您会看到更多这方面的内容。

转换限制

由 CodeConverter 处理的转换很有用,但是有局限性。例如,如果希望在调用目标方法之前或者之后调用一个监视方法,那么这个监视方法必须定义为 static void 并且必须先接受一个目标方法的类的参数,然后是与目标方法所要求的同样数量和类型的参数。

这种严格的结构意味着监视方法需要与目标类和方法完全匹配。举一个例子,假设我改变了 清单 1 中 reportSet() 方法的定义,让它接受一个一般性的 java.lang.Object 参数,想使它可以用于不同的目标类:

    public static void reportSet(Object target, String value) {
        System.out.println("Call to set value " + value);
    }

编译没有问题,但是当我运行它时它就会中断:

[dennis]$ java -cp .:javassist.jar TranslateConvert Bean setA BeanTest
Bean values are A and B
java.lang.NoSuchMethodError: TranslateConvert.reportSet(LBean;Ljava/lang/String;)V
        at BeanTest.changeValues(BeanTest.java:17)
        at BeanTest.main(BeanTest.java:23)
        at ...

有办法绕过这种限制。一种解决方案是在运行时实际生成与目标方法相匹配的自定义监视方法。不过这要做很多工作,在本文中我不打算试验这种方法。幸运的是,Javassist 还提供了另一种处理系统字节码转换的方法。这种方法使用 javassist.ExprEditor ,与 CodeConverter 相比,它更灵活、也更强大。

容易的类剖析

用 CodeConverter 进行字节码转换与用 javassist.ExprEditor 的原理一样。不过, ExprEditor 方式也许更难理解一些,所以我首先展示基本原理,然后再加入实际的转换。

清单 3 显示了如何用 ExprEditor 来报告面向方面的转换的可能目标的基本项目。这里我在自己的 VerboseEditor 中派生了 ExprEditor 子类,重写了三个基本的类方法 ―― 它们的名字都是 edit() ,但是有不同的参数类型。如 清单 1 中的代码,我实际上是在DissectionTranslator 内部类的 onWrite() 方法中使用这个子类,对从 ClassPool 实例中加载的每一个类,在对类对象的 instrument() 方法的调用中传递一个实例。

清单 3. 一个类剖析程序
public class Dissect
{
    public static void main(String[] args) {
        if (args.length >= 1) {
            try {
                
                // set up class loader with translator
                Translator xlat = new DissectionTranslator();
                ClassPool pool = ClassPool.getDefault(xlat);
                Loader loader = new Loader(pool);
                    
                // invoke the "main" method of the application class
                String[] pargs = new String[args.length-1];
                System.arraycopy(args, 1, pargs, 0, pargs.length);
                loader.run(args[0], pargs);
                
            } catch (Throwable ex) {
                ex.printStackTrace();
            }
            
        } else {
            System.out.println
                ("Usage: Dissect main-class args...");
        }
    }
    
    public static class DissectionTranslator implements Translator
    {
        public void start(ClassPool pool) {}
        
        public void onWrite(ClassPool pool, String cname)
            throws NotFoundException, CannotCompileException {
            System.out.println("Dissecting class " + cname);
            CtClass clas = pool.get(cname);
            clas.instrument(new VerboseEditor());
        }
    }
    
    public static class VerboseEditor extends ExprEditor
    {
        private String from(Expr expr) {
            CtBehavior source = expr.where();
            return " in " + source.getName() + "(" + expr.getFileName() + ":" +
                expr.getLineNumber() + ")";
        }
        public void edit(FieldAccess arg) {
            String dir = arg.isReader() ? "read" : "write";
            System.out.println(" " + dir + " of " + arg.getClassName() +
                "." + arg.getFieldName() + from(arg));
        }
        public void edit(MethodCall arg) {
            System.out.println(" call to " + arg.getClassName() + "." +
                arg.getMethodName() + from(arg));
        }
        public void edit(NewExpr arg) {
            System.out.println(" new " + arg.getClassName() + from(arg));
        }
    }
}

清单 4 显示了对 清单 2 中的 BeanTest 程序运行清单 3 中的 Dissect 程序所产生的输出。它给出了加载的每一个类的每一个方法中所做的工作的详细分析,列出了所有方法调用、字段访问和新对象创建。

清单 4. 已剖析的 BeanTest
[dennis]$ java -cp .:javassist.jar Dissect BeanTest
Dissecting class BeanTest
 new Bean in BeanTest(BeanTest.java:7)
 write of BeanTest.m_bean in BeanTest(BeanTest.java:7)
 read of java.lang.System.out in print(BeanTest.java:11)
 new java.lang.StringBuffer in print(BeanTest.java:11)
 call to java.lang.StringBuffer.append in print(BeanTest.java:11)
 read of BeanTest.m_bean in print(BeanTest.java:11)
 call to Bean.getA in print(BeanTest.java:11)
 call to java.lang.StringBuffer.append in print(BeanTest.java:11)
 call to java.lang.StringBuffer.append in print(BeanTest.java:11)
 read of BeanTest.m_bean in print(BeanTest.java:11)
 call to Bean.getB in print(BeanTest.java:11)
 call to java.lang.StringBuffer.append in print(BeanTest.java:11)
 call to java.lang.StringBuffer.toString in print(BeanTest.java:11)
 call to java.io.PrintStream.println in print(BeanTest.java:11)
 read of BeanTest.m_bean in changeValues(BeanTest.java:16)
 new java.lang.StringBuffer in changeValues(BeanTest.java:16)
 call to java.lang.StringBuffer.append in changeValues(BeanTest.java:16)
 call to java.lang.StringBuffer.append in changeValues(BeanTest.java:16)
 call to java.lang.StringBuffer.toString in changeValues(BeanTest.java:16)
 call to Bean.setA in changeValues(BeanTest.java:16)
 read of BeanTest.m_bean in changeValues(BeanTest.java:17)
 new java.lang.StringBuffer in changeValues(BeanTest.java:17)
 call to java.lang.StringBuffer.append in changeValues(BeanTest.java:17)
 call to java.lang.StringBuffer.append in changeValues(BeanTest.java:17)
 call to java.lang.StringBuffer.toString in changeValues(BeanTest.java:17)
 call to Bean.setB in changeValues(BeanTest.java:17)
 new BeanTest in main(BeanTest.java:21)
 call to BeanTest.print in main(BeanTest.java:22)
 call to BeanTest.changeValues in main(BeanTest.java:23)
 call to BeanTest.print in main(BeanTest.java:24)
Dissecting class Bean
 write of Bean.m_a in Bean(Bean.java:10)
 write of Bean.m_b in Bean(Bean.java:11)
 read of Bean.m_a in getA(Bean.java:15)
 read of Bean.m_b in getB(Bean.java:19)
 write of Bean.m_a in setA(Bean.java:23)
 write of Bean.m_b in setB(Bean.java:27)
Bean values are originalA and originalB
Bean values are newA and newB

通过在 VerboseEditor 中实现适当的方法,可以容易地增加对报告强制类型转换、 instanceof 检查和 catch 块的支持。但是只列出有关这些组件项的信息有些乏味,所以让我们来实际修改项目吧。

进行剖析

清单 4对类的剖析列出了基本组件操作。容易看出在实现面向方面的功能时使用这些操作会多么有用。例如,报告对所选字段的所有写访问的记录器(logger)在许多应用程序中都会发挥作用。无论如何,我已经承诺要为您介绍如何完成 这类工作。

幸运的是,就本文讨论的主题来说, ExprEditor 不但让我知道代码中有什么操作,它还让我可以修改所报告的操作。在不同的ExprEditor.edit() 方法调用中传递的参数类型分别定义一种 replace() 方法。如果向这个方法传递一个普通 Javassist 源代码格式的语句(在 第 4 部分中介绍),那么这个语句将编译为字节码,并且用来替换原来的操作。这使对字节码的切片和切块变得容易。

清单 5 显示了一个代码替换的应用程序。在这里我不是记录操作,而是选择实际修改存储在所选字段中的 String 值。在 FieldSetEditor中,我实现了匹配字段访问的方法签名。在这个方法中,我只检查两样东西:字段名是否是我所查找的,操作是否是一个存储过程。找到匹配后,就用使用实际的 TranslateEditor 应用程序类中 reverse() 方法调用的结果来替换原来的存储。 reverse() 方法就是将原来字符串中的字母顺序颠倒并输出一条消息表明它已经使用过了。

清单 5. 颠倒字符串集
public class TranslateEditor
{
    public static void main(String[] args) {
        if (args.length >= 3) {
            try {
                
                // set up class loader with translator
                EditorTranslator xlat =
                    new EditorTranslator(args[0], new FieldSetEditor(args[1]));
                ClassPool pool = ClassPool.getDefault(xlat);
                Loader loader = new Loader(pool);
                
                // invoke the "main" method of the application class
                String[] pargs = new String[args.length-3];
                System.arraycopy(args, 3, pargs, 0, pargs.length);
                loader.run(args[2], pargs);
                
            } catch (Throwable ex) {
                ex.printStackTrace();
            }
            
        } else {
            System.out.println("Usage: TranslateEditor clas-name " +
              "field-name main-class args...");
        }
    }
    
    public static String reverse(String value) {
        int length = value.length();
        StringBuffer buff = new StringBuffer(length);
        for (int i = length-1; i >= 0; i--) {
            buff.append(value.charAt(i));
        }
        System.out.println("TranslateEditor.reverse returning " + buff);
        return buff.toString();
    }
    
    public static class EditorTranslator implements Translator
    {
        private String m_className;
        private ExprEditor m_editor;
        
        private EditorTranslator(String cname, ExprEditor editor) {
            m_className = cname;
            m_editor = editor;
        }
        
        public void start(ClassPool pool) {}
        
        public void onWrite(ClassPool pool, String cname)
            throws NotFoundException, CannotCompileException {
            if (cname.equals(m_className)) {
                CtClass clas = pool.get(cname);
                clas.instrument(m_editor);
            }
        }
    }
    
    public static class FieldSetEditor extends ExprEditor
    {
        private String m_fieldName;
        
        private FieldSetEditor(String fname) {
            m_fieldName = fname;
        }
        
        public void edit(FieldAccess arg) throws CannotCompileException {
            if (arg.getFieldName().equals(m_fieldName) && arg.isWriter()) {
                StringBuffer code = new StringBuffer();
                code.append("$0.");
                code.append(arg.getFieldName());
                code.append("=TranslateEditor.reverse($1);");
                arg.replace(code.toString());
            }
        }
    }
}

如果对 清单 2 中的 BeanTest 程序运行清单 5 中的 TranslateEditor 程序,结果如下:

[dennis]$ java -cp .:javassist.jar TranslateEditor Bean m_a BeanTest
TranslateEditor.reverse returning Alanigiro
Bean values are Alanigiro and originalB
TranslateEditor.reverse returning Awen
Bean values are Awen and newB

我成功地在每一次存储到 Bean.m_a 字段时,加入了一个对添加的代码的调用(一次是在构造函数中,一次是在 set 方法中)。我可以通过对从字段的加载实现类似的修改而得到反向的效果,不过我个人认为颠倒值比开始使用的值有意思得多,所以我选择使用它们。

包装 Javassist

本文介绍了用 Javassist 可以容易地完成系统字节码转换。将本文与上两期文章结合在一起,您应该有了在 Java 应用程序中实现自己面向方面的转换的坚实基础,这个转换过程可以作为单独的编译步骤,也可以在运行时完成。

要想对这种方法的强大之处有更好的了解,还可以分析用 Javassis 建立的 JBoss Aspect Oriented Programming Project (JBossAOP)。JBossAOP 使用一个 XML 配置文件来定义在应用程序类中完成的所有不同的操作。其中包括对字段访问或者方法调用使用拦截器,在现有类中添加 mix-in 接口实现等。JBossAOP 将被加入正在开发的 JBoss 应用程序服务器版本中,但是也可以在 JBoss 以外作为单独的工具提供给应用程序使用。

本系列的下一步将介绍 Byte Code Engineering Library (BCEL),这是 Apache Software Foundation 的 Jakarta 项目的一部分。BCEL 是 Java classworking 最广泛使用的一种框架。它使用与我们在最近这三篇文章中看到的 Javassist 方法的不同方法处理字节码,注重个别的字节码指令而不是 Javassist 所强调的源代码级别的工作。下个月将分析在字节码汇编器(assembler)级别工作的全部细节。

参考资料

  • 您可以参阅本文在 developerWorks 全球站点上的 英文原文.
  • 参阅 Dennis Sosnoski 的 Java 编程的动态性系列的其他部分。
  • 下载本文的 示例代码
  • Javassist 是东京技术学院数学和计算机科学系的 Shigeru Chiba 开发的。它最近加入了开放源代码 JBoss 应用服务器项目,并成为其中新增加的面向方面的编程功能的基础。从 Sourceforge 上的 JBoss 项目文件页面下载 Javassist 的最新版本。
  • 想看用 Javassist 构建的面向方面的编程框架吗?请看 JBossAOP 项目
  • 从 Peter Haggar 的“ Java bytecode: Understanding bytecode makes you a better programmer”( developerWorks,2001 年 7 月)中学习更多关于 Java 字节码设计的内容。
  • 希望找到有关面向方面编程的更多内容吗?请参阅 Nicholas Lesiechi 的“ Improve modularity with aspect-oriented programming”( developerWorks,2002 年 1 月),以了解有关使用 Aspectj 语言的简要介绍。Andrew Glover 新发表的文章“ AOP banishes the tight-coupling blues”( developerWorks,2004 年 2 月)展示了 AOP 的一个功能设计概念 ―― 静态横切 ―― 是如何将缠绕在一起的、紧密耦合的代码转变为强大的、可扩展的企业应用程序。
  • 开放源代码 Jikes 项目为 Java 编程语言提供了一个非常快速和高度兼容的编译器。用它以老的方式(即从 Java 源代码)生成字节码。
  • 访问 Developer Bookstore,获得技术图书的完整列表,其中包括数百本 Java 相关的图书。
  • 在 developerWorks Java 技术专区 上有数百篇关于 Java 技术各个方面的文章。