Classworking工具箱:注释(Annotation)与ASM

到 J2SE 5.0,Sun 已经给 Java 平台添加了许多新特性。最为重要的一个新特性是支持注释。注释在关联多种类型的元数据与 Java 代码方面将会很有用,并且在扩展 Java 平台的新的和更新的 JSR 中,它已经被广泛用来代替定制配置文件。在本文中,我将向您展示如何结合使用 ASM 字节码操作框架和 J2SE 5.0 的新增特性 —— instrumentation 包 —— 来在类被加载到 JVM 中时,按照注释的控制来转换类。

注释基础知识

讨论 J2SE 5.0 注释的文章已经很多了,所以在此我只作一个简短的归纳。注释是一种针对 Java 代码的元数据。在功能上类似于日益普及的用于处理复杂框架配置的 XDoclet 样式的元数据,而其实现则与 C# 属性有更多的共同点。

该语言特性的 Java 实现使用一种类似于接口的结构和 Java 语言语法的一些特殊扩展。我发现大多数情况下忽略这种类似于接口的结构,而把注释看作是名值对的 hashmap 会更清晰。每个注释类型定义了一组与之关联的固定名称。每个名称可能被赋予一个默认值,否则的话每次使用该注释时都要定义该名称。注释可以被指定应用于一种特定类型的 Java 组件(如类、字段、方法,等等),甚至还可以应用于其他的注释。(实际上,您是通过在要限制的注释的定义上使用一个特殊的预定义注释,来限制注释适用的组件的。)

不同于常规接口,注释必须在定义中使用关键字 @interface。同样不同于常规接口的是,注释只能定义不带参数且只返回简单值(基本类型、String、 Class、enum 类型、注释,以及任意这些类型的数组)的“方法”。这些“方法”是与注释关联的值的名称。

注释被用作声明时的修饰符,就像 public、final,以及其他早于 J2SE 5.0 版本的 Java 语言所定义的关键字修饰符。注释的使用是由 @ 符号后面跟注释名来表明的。如果要给注释赋值,在注释名后面的圆括号中以名值对的形式给出。

清单 1 展示了一个示例注释声明,后面是将该注释用于某些方法的类的定义。该 LogMe 注释用来标记应该包含在应用程序的日志记录中的方法。我已经给该注释赋了两个值:一个表示该调用被包含其中的日志记录的级别,另一个表示用于该方法调用的名称(默认是空字符串,假定没有名称时,处理该注释的代码将代入实际的方法名)。然后我将该注释用于 StringArray 类中的两个方法,对 merge() 方法只使用默认值,对 indexOf() 方法则提供显式值。

清单 1. 反射代替接口及其实现

import java.lang.annotation.ElementType;import java.lang.annotation.Target;/**  * Annotation for method to be included in logging.  */@Target({ElementType.METHOD})public @interface LogMe {   int level() default 0;   String name() default "";}public class StringArray{   private final String[] m_list;   public StringArray(String[] list) {     ...   }   public StringArray(StringArray base, String[] adds) {     ...   }   @LogMe private String[] merge(String[] list1, String[]list2) {     ...   }   public String get(int index) {     return m_list[index];   }   @LogMe(level=1, name="lookup") public int indexOf(String value) {     ...   }   public int size() {     return m_list.length;   }}

下一小节我将介绍一个不同的(我认为是更有趣的)应用程序。

构建 toString() 方法

Java 平台提供了一个方便的挂钩,以生成 toString() 方法形式的对象的文本描述。最终基类 java.lang.Object 提供了该方法的一个默认实现,但是仍鼓励重写默认实现以提供更有用的描述。许多开发人员习惯提供自己的实现,至少对于那些基本上是数据表示的类是这样。我要先承认我不是其中之一 —— 我常常认为 toString() 非常有用,一般不会费心去重写默认实现。为了更有用些,当从类中添加或删除字段时,toString() 实现需要保持最新。而我发现总的来说这一步太麻烦而不值得实现。

把注释与类文件修改组合起来可以提供一种走出这一困境的方法。我所遇到的维护 toString() 方法的问题是由于代码与类中的字段声明分离了,这意味着每次添加或删除字段时还有一个需要记得更改的东西。通过在字段声明时使用注释,可以很容易地表明想要在 toString() 方法中包含哪些字段,而把该方法的实际实现留给 classworking 工具。这样,所有的东西都在一个地方(字段声明中),而且获得了 toString() 的有用的描述而无需维护代码。

源代码示例

在实现 toString() 方法结构的注释之前,我将给出要实现的代码示例。清单 2 展示了源代码中包含 toString() 方法的示例数据保持类:

清单 2. 带有 toString() 方法的数据类

public class Address{   private String m_street;   private String m_city;   private String m_state;   private String m_zip;   public Address() {}   public Address(String street, String city, String state, String zip) {     m_street = street;     m_city = city;     m_state = state;     m_zip = zip;   }   public String getCity() {     return m_city;   }   public void setCity(String city) {     m_city = city;   }   ...   public String toString() {     StringBuffer buff = new StringBuffer();     buff.append("Address: street=");     buff.append(m_street);     buff.append(", city=");     buff.append(m_city);     buff.append(", state=");     buff.append(m_state);     buff.append(", zip=");     buff.append(m_zip);     return buff.toString();   }}

对于清单 2 的示例,我选择在 toString() 输出中包含所有的字段,字段顺序与其在类中声明的顺序相同,并以“name=”文本来开始每个字段值,以在输出中标识它们。对于本例,文本是通过剥去用来标识成员字段的“m_”前缀,来直接从字段名生成的。在其他情况下,我可能想要在输出中仅包含某些字段、更改顺序、更改用于值的标识符文本,或者甚至完全跳过标识符文本。注释格式灵活得足以表示所有的可能。

定义注释

可以以多种方式为 toString() 的生成定义注释。为使它真正有用,我情愿最小化所需的注释数目,可能通过使用类注释来标志我想要在其中生成方法的类,并使用单个的字段注释来重写字段的默认处理。这并不太难做到,但是实现代码将变得相当复杂。对于本文来说,我想使它保持简单,因此只使用包含在实例的描述中的单个字段的注释。

我想要控制的因素有:要包含哪些字段,字段值是否有前导文本,该文本是否基于字段名,以及字段在输出中的顺序。清单 3 给出了一个针对该目的的基本注释:

清单 3. toString() 生成的注释

package com.sosnoski.asm;import java.lang.annotation.ElementType;import java.lang.annotation.Target;@Target({ElementType.FIELD})public @interface ToString {   int order() default 0;   String text() default "";}

清单 3 的注释只定义了一对命名值,给出了顺序和用于一个字段的前导文本。我已经用 @Target 行将该注释的使用限定到字段声明。我还为每个值定义了默认值。这些默认值并不应用于成为二进制类表示的生成的注释信息(只有当注释在运行时作为伪接口被访问时,它们才应用,而我不会这么做),所以我实际上并不关心使用什么值。我只是通过定义默认值,使值是可选的,而不必在每次使用注释时都指定它们。

使用注释时要记住的一个因素是,命名值必须始终是编译时常量,而且不能为 null。该规则适用于默认值(如果指定的话)和由用户设置的值。我猜测这个决定是基于与早期 Java 语言定义的一致性而做出的,但是我觉得奇怪的是,对 Java 语言做出如此重大修改的规范,却只局限于这一方面的一致性。

实现生成

既然已经打好了基础,就该研究实现 classworking 转换了:当载入带注释的类时向它们添加 toString() 方法。该实现涉及三个单独的代码段:截获 classloading、访问注释信息和实际转换。

用 instrumentation 来截获

J2SE 5.0 给 Java 平台添加了许多特性。就我个人而言,我并不认为所有这些添加的特性都是改进。但是,有两个不太引人注意的新特性确实对 classworking 很有用,就是 java.lang.instrument 包和 JVM 接口,它们使您可以指定将在执行程序时使用的类转换代理,当然还有其他功能。

要使用转换代理,需要在启动 JVM 时指定代理类。当使用 java 命令来运行 JVM 时,可以使用命令行参数,以 -javaagent:jarpath[=options] 的形式来指定代理,其中“jarpath”是到包含代理类的 JAR 文件的路径,而“options”是代理的参数串。代理 JAR 文件使用一个特殊的清单属性来指定实际的代理类,这必须定义一个方法: public static void premain(String options, Instrumentation inst)。 该代理 premain() 方法将先于应用程序的 main() 方法调用,而且能够使用传入的 java.lang.instrument.Instrumentation 类实例注册实际的转换器。

该转换器类必须实现 java.lang.instrument.ClassFileTransformer 接口,后者定义了一个 transform() 方法。当使用 Instrumentation 类实例注册一个转换器实例时,将会为在 JVM 中创建的每个类调用该转换器实例。转换器将获得到二进制类表示的访问,并且可以在类表示被 JVM 加载之前修改它。

清单 4 给出了处理注释的代理和转换器类(在本例中是同一个类,但是这二者不一定要相同)实现。 transform() 实现使用 ASM 来扫描提供的二进制类表示,并寻找适当的注释,收集关于该类的带注释字段的信息。如果找到带注释的字段,该类将被修改以包含生成的 toString() 方法,而修改后的二进制表示将被返回。否则 transform() 方法只返回 null,表明没有必要进行修改。

清单 4. 代理和转换器类

package com.sosnoski.asm;import java.lang.instrument.ClassFileTransformer;import java.lang.instrument.IllegalClassFormatException;import java.lang.instrument.Instrumentation;import java.security.ProtectionDomain;import org.objectweb.asm.ClassReader;import org.objectweb.asm.ClassWriter;public class ToStringAgent implements ClassFileTransformer{   // transformer interface implementation   public byte[] transform(ClassLoader loader, String cname, Class class,     ProtectionDomain domain, byte[] bytes)     throws IllegalClassFormatException {     System.out.println("Processing class " + cname);     try {       // scan class binary format to find fields for toString() method       ClassReader creader = new ClassReader(bytes);       FieldCollecTor visiTor = new FieldCollecTor();       creader.accept(visiTor, true);       FieldInfo[] fields = visiTor.getFields();       if (fields.length > 0) {         // annotated fields present, generate the toString() method         System.out.println("Modifying " + cname);         ClassWriter writer = new ClassWriter(false);         ToStringGeneraTor gen = new ToStringGeneraTor(writer,             cname.replace('.', '/'), fields);         creader.accept(gen, false);         return writer.toByteArray();       }     } catch (IllegalStateException e) {       throw new IllegalClassFormatException("Error: " + e.getMessage() +         " on class " + cname);     }     return null;   }   // Required method for instrumentation agent.   public static void premain(String arglist, Instrumentation inst) {     inst.addTransformer(new ToStringAgent());   }}

J2SE 5.0 的 instrumentation 特性远远不止是我在此所展示的,它包括访问加载到 JVM 中的所有类,甚至重定义已有类(如果 JVM 支持的话)的能力。对于本文,我将跳过其他的特性,继续来看用于处理注释和修改类的 ASM 代码。

累积元数据

ASM 2.0 使处理注释变得更容易了。正如您在 上个月的文章 中了解到的,ASM 使用 visiTor 的方法来报告类数据的所有组件。J2SE 5.0 注释是使用 org.objectweb.asm.AnnotationVisiTor 接口报告的。该接口定义了几个方法,其中我将只使用两个:visitAnnotation() 是处理注释时调用的方法,而 visit() 是处理注释的特定的名值对时调用的方法。我还需要实际字段信息,这是使用基本 org.objectweb.asm.ClassVisiTor 接口中的 visitField() 方法报告的。

实现感兴趣的两个接口的所有方法将是冗长乏味的,但幸运的是 ASM 提供了一个方便的 org.objectweb.asm.commons.EmptyVisiTor 类,作为编写自己的 visiTor 的基础。EmptyVisiTor 只是提供了所有不同种类的 visiTor 的空的实现,允许您只对感兴趣的 visiTor 方法建子类和重写。清单 5 给出了扩展 EmptyVisiTor 类而得到的处理 ToString 注释的 FieldCollecTor 类。清单中也包含了用来保存收集的字段信息的 FieldInfo 类。

清单 5. 处理类的注释

package com.sosnoski.asm;import java.util.ArrayList;import java.util.Arrays;import org.objectweb.asm.AnnotationVisiTor;import org.objectweb.asm.FieldVisiTor;import org.objectweb.asm.Opcodes;import org.objectweb.asm.Type;import org.objectweb.asm.commons.EmptyVisiTor;/**  * VisiTor implementation to collect field annotation information from class.  */public class FieldCollecTor extends EmptyVisiTor{   private boolean m_isIncluded;   private int m_fieldAccess;   private String m_fieldName;   private Type m_fieldType;   private int m_fieldOrder;   private String m_fieldText;   private ArrayList m_fields = new ArrayList();   // finish field handling, once we're past it   private void finishField() {     if (m_isIncluded) {       m_fields.add(new FieldInfo(m_fieldName, m_fieldType,         m_fieldOrder, m_fieldText));     }     m_isIncluded = false;   }   // return array of included field information   public FieldInfo[] getFields() {     finishField();     FieldInfo[] infos =       (FieldInfo[])m_fields.toArray(new FieldInfo[m_fields.size()]);     Arrays.sort(infos);     return infos;   }   // process field found in class   public FieldVisiTor visitField(int Access, String name, String desc,     String sig, Object init) {     // finish processing of last field     finishField();     // save information for this field     m_fieldAccess = Access;     m_fieldName = name;     m_fieldType = Type.getReturnType(desc);     m_fieldOrder = Integer.MAX_VALUE;     // default text is empty if non-String object, otherwise from field name     if (m_fieldType.getSort() == Type.OBJECT &&       !m_fieldType.getClassName().equals("java.lang.String")) {       m_fieldText = "";     } else {       String text = name;       if (text.startsWith("m_") && text.length() > 2) {         text = Character.toLowerCase(text.charAt(2)) +           text.substring(3);       }       m_fieldText = text;     }     return super.visitField(Access, name, desc, sig, init);   }   // process annotation found in class   public AnnotationVisiTor visitAnnotation(String sig, boolean visible) {     // flag field to be included in representation     if (sig.equals("Lcom/sosnoski/asm/ToString;")) {       if ((m_fieldAccess & Opcodes.ACC_STATIC) == 0) {         m_isIncluded = true;       } else {         throw new IllegalStateException("ToString " +           "annotation is not supported for static field +" +           " m_fieldName");       }     }     return super.visitAnnotation(sig, visible);   }   // process annotation name-value pair found in class   public void visit(String name, Object value) {     // ignore anything except the pair defined for toString() use     if ("order".equals(name)) {       m_fieldOrder = ((Integer)value).intValue();     } else if ("text".equals(name)) {       m_fieldText = value.toString();     }   }}package com.sosnoski.asm;import org.objectweb.asm.Type;/**  * Information for field value to be included in string representation.  */public class FieldInfo implements Comparable{   private final String m_field;   private final Type m_type;   private final int m_order;   private final String m_text;   public FieldInfo(String field, Type type, int order,     String text) {     m_field = field;     m_type = type;     m_order = order;     m_text = text;   }   public String getField() {     return m_field;   }   public Type getType() {     return m_type;   }   public int geTorder() {     return m_order;   }   public String getText() {     return m_text;   }   /* (non-Javadoc)    * @see java.lang.Comparable#compareTo(java.lang.Object)    */   public int compareTo(Object comp) {     if (comp instanceof FieldInfo) {       return m_order - ((FieldInfo)comp).m_order;     } else {       throw new IllegalArgumentException("Wrong type for comparison");     }   }}

清单 5 的代码保存了访问字段时的字段信息,因为如果该字段有注释呈现的话,以后将会需要该信息。当访问注释时,该代码审查它是否是 ToString 注释,如果是,设置一个标志,说明当前字段应该被包含在用于生成 toString() 方法的列表中。当访问一个注释名值对时,该代码审查由 ToString 注释定义的两个名称,当找到时,保存每个名称的值。这些名称的真正默认值(与在注释定义中使用的默认值相对)是在字段的 visiTor 方法中设置的,所以任意由用户指定的值都将重写这些默认值。

ASM 首先访问字段,接着访问注释和注释值。因为在处理字段的注释时,没有特定的方法可以调用,所以当处理一个新字段和当需要字段的完成列表时,我会调用一个 finishField() 方法。getFields() 方法向调用者提供字段的完成列表,以由注释值所确定的顺序排列。

转换类

清单 6 展示了实现代码的最后部分,它实际上向类添加了 toString() 方法。该代码与 上个月的文章 中使用 ASM 构造一个类的代码类似,但是需要另外构造以修改一个已有的类。这里,ASM 使用的 visiTor 方法增加了复杂性 —— 要修改一个已有的类,需要访问所有的当前类目录,并把它传递给类编写者。org.objectweb.asm.ClassAdapter 是针对此目的的一个方便的基类。它实现了对提供的类编写者实例的传递处理,使您可以只重写需要特殊处理的方法。

清单 6. 添加 toString() 方法

package com.sosnoski.asm;import org.objectweb.asm.ClassAdapter;import org.objectweb.asm.ClassWriter;import org.objectweb.asm.MethodVisiTor;import org.objectweb.asm.Opcodes;import org.objectweb.asm.Type;/**  * VisiTor to add toString method to a class.  */public class ToStringGeneraTor extends ClassAdapter{   private final ClassWriter m_writer;   private final String m_internalName;   private final FieldInfo[] m_fields;   public ToStringGeneraTor(ClassWriter cw, String iname, FieldInfo[] props) {     super(cw);     m_writer = cw;     m_internalName = iname;     m_fields = props;   }   // called at end of class   public void visitEnd() {     // set up to build the toString() method     MethodVisiTor mv = m_writer.visitMethod(Opcodes.ACC_PUBLIC,       "toString", "()Ljava/lang/String;", null, null);     mv.visitCode();     // create and initialize StringBuffer instance     mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuffer");     mv.visitInsn(Opcodes.DUP);     mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuffer",       "", "()V");     // start text with class name     String name = m_internalName;     int split = name.lastIndexOf('/');     if (split >= 0) {       name = name.substring(split+1);     }     mv.visitLdcInsn(name + ":");     mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuffer",       "append", "(Ljava/lang/String;)Ljava/lang/StringBuffer;");     // loop through all field values to be included     boolean newline = false;     for (int i = 0; i  0) {         lead += prop.getText() + "=";       }       mv.visitLdcInsn(lead);       mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,         "java/lang/StringBuffer", "append",         "(Ljava/lang/String;)Ljava/lang/StringBuffer;");       // load the actual field value and append       mv.visitVarInsn(Opcodes.ALOAD, 0);       mv.visitFieldInsn(Opcodes.GETFIELD, m_internalName,         prop.getField(), type.getDescripTor());       if (isobj) {         // convert objects by calling toString() method         mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,           type.getInternalName(), "toString",           "()Ljava/lang/String;");         mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,           "java/lang/StringBuffer", "append",           "(Ljava/lang/String;)Ljava/lang/StringBuffer;");       } else {         // append other types directly to StringBuffer         mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,           "java/lang/StringBuffer", "append", "(" +           type.getDescripTor() + ")Ljava/lang/StringBuffer;");       }       newline = isobj;     }     // finish the method by returning accumulated text     mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuffer",       "toString", "()Ljava/lang/String;");     mv.visitInsn(Opcodes.ARETURN);     mv.visitMaxs(3, 1);     mv.visitEnd();     super.visitEnd();   }}

在清单 6 中,需要重写的惟一方法就是 visitEnd() 方法。该方法在所有的已有类信息都已经被访问之后调用,所以它对于添加新内容非常方便。我已经用 visitEnd() 方法向正在处理的类添加 toString() 方法。在代码生成中,我已经添加了一些用于精密地格式化 toString() 输出的特性,但是基本原理很简单 —— 只是循环遍历字段数组,生成代码,该代码首先向 StringBuffer 实例追加前导文本,然后追加实际字段值。

因为当前的代码将只使用 J2SE 5.0(由于使用了 instrumentation 方法来截获 classloading),所以我本应该使用新的 StringBuilder 类作为 StringBuffer 的更有效的等价物。我之所以选择使用以前的方案,是因为下一篇文章中我将使用该代码进行一些后续工作,但是您应该记住 StringBuilder 以便用于您自己的特定于 J2SE 5.0 的代码。

运行 ToString

清单 7 展示了 ToString 注释的一些测试类。我对实际注释使用了混合样式,在一些情况中指定了名值对,而其他的则只使用注释本身。Run 类创建带示例数据的 Customer 类实例,并打印出 toString() 方法调用的结果。

清单 7. ToString 的测试类

package com.sosnoski.dwct;import com.sosnoski.asm.ToString;public class Customer{   @ToString(order=1, text="#") private long m_number;   @ToString() private String m_homePhone;   @ToString() private String m_dayPhone;   @ToString(order=2) private Name m_name;   @ToString(order=3) private Address m_address;   public Customer() {}   public Customer(long number, Name name, Address address, String homeph,     String dayph) {     m_number = number;     m_name = name;     m_address = address;     m_homePhone = homeph;     m_dayPhone = dayph;   }   ...}...public class Address{   @ToString private String m_street;   @ToString private String m_city;   @ToString private String m_state;   @ToString private String m_zip;   public Address() {}   public Address(String street, String city, String state, String zip) {     m_street = street;     m_city = city;     m_state = state;     m_zip = zip;   }   public String getCity() {     return m_city;   }   public void setCity(String city) {     m_city = city;   }   ...}...public class Name{   @ToString(order=1, text="") private String m_first;   @ToString(order=2, text="") private String m_middle;   @ToString(order=3, text="") private String m_last;   public Name() {}   public Name(String first, String middle, String last) {     m_first = first;     m_middle = middle;     m_last = last;   }   public String getFirst() {     return m_first;   }   public void setFirst(String first) {     m_first = first;   }   ...}...public class Run{   public static void main(String[] args) {     Name name = new Name("Dennis", "Michael", "Sosnoski");     Address address = new Address("1234 5th St.", "Redmond", "WA", "98052");     Customer customer = new Customer(12345, name, address,       "425 555-1212", "425 555-1213");     System.out.println(customer);   }}

最后,清单 8 展示了测试运行的控制台输出(首行被折行以适合屏幕):

清单 8. 测试运行的控制台输出(首行被折行)

[dennis@notebook code]$ java -cp lib/asm-2.0.RC1.jar:lib/asm-commons-2.0.RC1.jar  :lib/tostring-agent.jar:classes -javaagent:lib/tostring-agent.jar  com.sosnoski.dwct.RunProcessing class sun/misc/URLClassPath$FileLoader$1Processing class com/sosnoski/dwct/RunProcessing class com/sosnoski/dwct/NameModifying com/sosnoski/dwct/NameProcessing class com/sosnoski/dwct/AddressModifying com/sosnoski/dwct/AddressProcessing class com/sosnoski/dwct/CustomerModifying com/sosnoski/dwct/CustomerCustomer: #=12345  Name: Dennis Michael Sosnoski  Address: street=1234 5th St. city=Redmond state=WA zip=98052  homePhone=425 555-1212 dayPhone=425 555-1213

结束语

我已经演示了如何使用 ASM 和 J2SE 5.0 注释来完成自动的运行时类文件修改。我用作例子的 ToString 注释是有趣而且(至少对于我来说)比较有用的。单独使用时,并不妨碍代码的可读性。但是注释如果被用于各种不同目的(这种情况将来肯定要发生,因为有如此多的 Java 扩展正在编写或重写以使用注释),就很有可能会影响代码的可读性。

当我在后面的文章中研究注释和外部配置文件的权衡时,我会再回到这个问题上。我个人的观点是,二者都有自己的作用,虽然注释基本上是作为配置文件的更容易的替代方案而开发的,但是独立的配置文件在某些情况下仍然适用。明确地讲,我认为 ToString 注释是一个适当使用的例子!

使用 J2SE 5.0 扩展的一个局限是 JDK 1.5 编译器输出只能与 JDK 1.5 JVM 一起使用。下一篇 Classworking 工具箱 文章,我将介绍一个克服该局限的工具,并展示如何修改 ToString 实现以运行在以前的JVM 上。

本文配套源码

临行之前,面对太多的疑问和不解:

Classworking工具箱:注释(Annotation)与ASM

相关文章:

你感兴趣的文章:

标签云: