到 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 上。
本文配套源码
临行之前,面对太多的疑问和不解: