Java重定义接口实现代码的自动注入

2013-09-18 10:30严忠林
微型电脑应用 2013年8期
关键词:代码定义成员

严忠林

0 引言

接口是 Java语言中重要的程序组件,许多广泛使用的应用框架,比如spring等,都是基于接口工作的。设计者利用接口可以摆脱具体实现的羁绊,从问题处理的本身需求出发,定义对象间通讯的方法。它可以超越类继承方面的限制,给实现代码保留最大的自由度,使整个系统低耦合、易扩展、更灵活通用。所以几乎每本介绍 Java编程的书都会建议程序员“基于接口编程”。

但接口机制也有一明显的缺陷。它必须由实现类来提供各方法的执行代码。因此一旦某接口有了较多数量的实现类,再要修改该接口就困难了。一个典型的例子是 JDK1.0中定义的 Enumeration接口,有 hasMoreElements和nextElement两个方法。当JDK1.2希望增加remove操作时,由于不能影响已实现类,只得又重新定义一个类同的新接口Iterator。这导致像Vector这样的相关类,不得不再添加一套类似的方法,它无疑提高了代码的复杂性和开发工作量。Java核心类库如此,用户自定义的接口同样也会遇到。在软件的整个生命周期中,用户需求、运行环境常发生变化,会要求已定义的接口也随之改变,比如需要添加新方法、修改参数列表等。这会使先前已完成的类不作修改就无法使用。假如这样的类数量不多,程序员依靠人工还能应付。但当它们数量较多,或者没有源代码时,就比较难解决了,有时会因此妨碍系统的优化升级。

这些由新情况、新要求带来的改变,一般不会导致已完成类的内部工作逻辑发生变化。多数情况可用相同的模式统一处理它们。比如对于为新需求而添加的方法,已有的类大都可用相同的代码完成处理。有时这些新方法在已有类中从不会被调用,因此只要提供不执行任何操作的空方法就行了。相比于用手工在源码层次上一个一个类重复地进行代码修改、编译、保存等操作,更好的方案是将这些对每个已完成类都相同的,需要添加、修改的元素,制作成统一的bytecode模板,再将它直接注入到相关类的类代码中,使这些类立刻满足新接口的要求。这样,无论有多少已完成类,都可以在极短时间内实现更新。

能够直接在bytecode层次上完成这一工作,还绕过了对源代码的需求。即使应用系统使用了来自开发团队外部的代码,也照样能进行必要的修正。

这种对整个系统的修复优化工作,一般都要经过若干次更新、测试、再更新、再测试的反复处理。为使这一过程简单高效,由虚拟机在运行时自动完成上述的代码注入是最方便的。这样,程序员仅需修改接口,准备好需要的模板,原有各实现类的类文件保持原样,就可启动系统,调试运行了。相关类代码的变更是在它被装载时自动完成的。不需要繁琐的操作,程序员就能获得期望的结果。

1 标注被更新的接口

为了声明哪些接口已作了修改,指示虚拟机进行处理,采用在对应接口前添加标注的办法,同时也用它指定应用的模板,如图1所示:

图1 实现注入的标注的定义及示例

定义了 ImplementInjection标注。虚拟机将检查所有实现这些接口的类,对其中缺少的或需替换的方法执行注入。

通常一个接口的所有实现类可使用同一模板,但有时也会遇到需要分门别类,给不同类型的实现类提供不同模板的情况,所以又使用 Implement数组来提供这些信息。Implement是为此定义的另一标注,它有两个属性,都是用字符串表示的类全名,implement指定作为注入模板的类文件,type给出使用该模板的类文件的类型信息,没有指定适用类型的模板将用于所有其他类。虚拟机将按照它们在数组中出现的次序对各相关类进行类型匹配。

图1中给出的示例对接口Abc进行了标注,它声明对类CA及其子类以ImpA、对实现IB接口的所有类以ImpB、其他实现类以ImpC作为模板进行注入。标注使用时也可以不提供任何模板,注入时将默认地用不执行任何操作但能满足语法要求的空方法来代替。

2 提供注入模板

对一个接口的修改,可能有以下几种情况。

删除某方法,对于已完成类,这不用作任何修改。

添加新方法,需要为已完成类注入新的实现代码。

添加或修改已有方法的参数列表,对于已完成类,这相当于添加新方法。

修改已有方法的返回值的类型,需要修改原实现代码。

修改已有方法抛出的异常列表,如果仅是增加异常类型,对已完成类没有影响。否则要修改原代码。

也存在表面上未作修改,但对具体实现提出新要求的情况,这也需要用新的实现代码替换已完成类中的旧代码。

模板中包含将在已完成的各实现类中添加或修改的方法代码,应由程序员根据实际情况编写。从语法上看,它是一个普通的Java类,编译后会形成标准格式的*.class文件。但实质上它仅是一个存放注入时所需代码的容器。虚拟机处理某个实现类时,将比对相关的接口及模板,判定需要添加或替换的元素,然后执行注入,使之成为该实现类的一部分。因此这些代码的真正运行环境是被注入的实现类,它可以直接访问该类里面原有的任何成员。

作为模板的java类中可能包含下列成员。

接口中新添加的方法,发现实现类中缺少该方法时将注入。

实现代码需要替换的方法,它们要加上额外的标注,指示处理时进行替换。

以上方法的代码所引用的其他数据和方法,它们又可分为:

原实现类中已有的成员,新添加的方法如果需要,当然可以使用它们。它们并不需要注入,出现在模板中是为满足模板编译时的语法要求。因此,只要类型声明和原有类一致,没有实际代码的空方法、未初始化的变量等,都可满足要求。注入处理时将直接跳过。

新添加的成员,它们是由于新注入代码运行的需要而引入的新元素,应该和这些代码一起注入。为区别于其他成员,也加以标注,以确保处理时能正确注入。

图1中也出现了这些标注的定义,Hold用于标注那些被注入代码所需要的新元素,只要某个类文件需要代码注入,它们就一定随之一起注入。即使原有类中已有了同名的元素,它们仍将在改名后被注入。当然,引用它们的代码也要作相应改变。

Replace用于标注那些需要用新代码替换类文件中旧代码的方法。有些情况下,该方法原有的处理逻辑仍然有效,新代码只不过是在其基础上添加一些额外处理,如进行类型转换或与新环境的交互等。此时对旧代码不能一删了之,而应修改名字后在新的实现代码中继续使用。标注中的 value属性就用于说明在此情况下,新代码中调用原方法所使用的名字,注入时要作适当处理。

3 成员注入的实现

Java编译后获得的类文件和其他语言的可执行文件不同,源程序结构和几乎所有的标识符信息依然保留其中,代码中对各变量、方法的使用都通过符号引用实现。正是由于这些特性,才使得代码注入简便可行。

SUN公司为类文件定义了规范的结构,理解并遵守这一规范[1],是正确实现注入的基础。其构成,如图2所示:

图2 Java类文件结构

其中常量池包含了类代码中用到的所有常量、名字和类型信息。类自身、父类及其实现的接口通过常量池索引连接起来,代码中各数据访问、方法调用也通过常量池中由名字、类型信息等组成的符号引用实现。类中出现的变量、方法及各种标注等属性都有规则地罗列,其内部信息也有明确的规范。因此,要判定是否需要注入,应该注入什么元素,只要查看类文件即可。注入操作也只是将需要的数据、方法成员从模板中取出,复制插入到类文件的合适位置中去。稍微复杂的是常量池的修改,需要添加或改变其中的元素。

直接解析类文件执行以上操作还是比较复杂的,但现在已有专门处理类文件的工具ASM。ASM是一个小型高效、使用方便的开源软件,可以在 Java虚拟机的“汇编语言”bytecode层上完成类文件的解析、生成、变换。它提供的基于事件的API[3],快速简洁,非常适合这里需要的注入操作。它隐藏了常量池等底层处理,提供了几个采用访问者模式,用户可方便实现所需功能的基本组件。其中ClassReader能解析已存在的类文件,对其中的不同元素,会调用用户提供的处理类的相应visit方法。ClassWriter提供能直接生成类文件中各元素二进制表示的方法,用户按合适次序调用,就能生成正确的类文件。ClassVisitor、FieldVisitor、MethodVisitor、AnnotationVisitor等为访问类文件中各元素定义了 visit方法,用户编写子类,用自己的处理逻辑覆盖相应方法,就可以实现各种希望的功能。只要将这些类按需连接成处理链,就能对类文件各元素进行获取、插入、删除、变换等操作[4]。

代码注入将类文件分成3种作不同处理。

定义接口的类文件:要检查标注,确定是否对其实现类进行注入操作。需要的话记录其中各方法,以及模板文件等信息。

定义模板的类文件:要记录其中各成员的相关信息。

定义普通类的类文件:首先要查看其是否实现了需要注入的接口。如否,不作任何处理。否则,选定应使用的模板。并查看内部已有成员及父类中可访问成员,明确需要注入或替换的元素。然后将模板中相应成员插入类文件中,同时完成改名等操作。对接口中要求注入而模板中未提供代码的方法,自动生成不执行任何操作,返回符合类型要求的0或null的空方法代替。

4 注入操作的自动执行

这种对类文件的变换处理,可以作为软件开发中一个单独的步骤进行,但当牵涉的文件量较多,或需要多次反复修改调试的场合,更方便的做法是让虚拟机自动发现需要注入的类,在使用前自动完成变换操作。

Java类是运行时按需动态装载的。虚拟机中有多个类装载器,负责获取不同来源的类文件,有些应用还会使用自定义的类装载器。每个类仅在程序首次运行至需要它时,才由某个类装载器进行装载。类文件从装载到可以使用要经过载入、连接、初始化等步骤[1],其中连接又包含代码合法性验证、存储空间获取和可选的引用类解析等操作,它们的执行还会递归地引发其父类、相关接口、引用类的类装载。

因此,要让虚拟机自动实现注入操作,就应该在类代码载入后连接前完成必要的判定、修改工作,用它替换原代码执行后续的装载流程。JDK中的Java.lang.instrument包可实现此功能[2]。它定义了Instrumentation和ClassFileTransformer两个接口。Instrumentation对象由虚拟机提供,用于设置和启动对类文件的检查、变换。ClassFileTransformer定义了transform方法,它由用户实现,具体完成对类代码的处理。该方法会在类装载器装载了新类,对其进行合法性验证之前执行,也会在 instrumentation对象对已装载类发出retransformClasses请求后执行。使用这个包时,用户还要提供一个包含premain方法的类,在其中用instrumentation对象注册用户的 transform方法。系统启动时用适当的命令行选项,使其在应用程序的main方法之前执行。这样就可以使程序的所有类文件在完成了期望的变换后才被执行。

在实现代码注入时,只要在 transform方法中引入对应处理即可。由于在进行注入时要参照所处理类的实现接口、父类以及插入模板,因此需要改变虚拟机载入它们的次序。对于尚未载入的父类、接口等,不能等到合法性验证时才装载,而需主动先行载入。对于先前已载入但未记录相应信息的父类,可通过instrumentation发出retransformClasses请求,重新得到它的类代码,获取需要的信息。这样,用户在修改了接口,并提供了模板文件后,就可运行原程序,所有的变换处理会在运行前自动完成。

在premain方法中还加入开关选项,指定类代码的存储位置,使变换后的类代码除了直接被虚拟机使用外,也存储起来。在所有的开发测试完成后,可以能将它们直接部署到生产环境中去。

5 总结

通过直接在类文件中注入接口需要的实现方法,较好地改变了已有大量实现类的接口不便于修改的境况。这有助于改善对已完成的大型软件进行升级优化的工作效率,也有助于提高已有软件的可复用性。实际上,这种做法适用于大量类都有相同方法代码的场合。可避免一模一样的文本不得不出现在多个地方的窘境。在仅支持单继承的 Java语言中,它对于系统的开发和维护是有益的。再进一步,这种做法还初步达成了接口的实现代码和其他代码的分离。对同一个接口,可以提供不同的具体实现,替换简单容易。这对于要为同一软件,根据不同运行环境,生成不同的子版本是有帮助的。

[1]Tim Lindholm, Frank Yellin. The Java Virtual Machine Specification Second Edition [M]Addison-Wesley Professional 1999.4

[2]Oracle co. Java Platform, Standard Edition 7 API Specification[EB/OL].http://docs.oracle.com/javase/7/docs/api/

[3]Eric Bruneton. ASM 4.0 A Java bytecode engineering library[EB/OL].http://download.forge.objectweb.org/asm/asm4-guide.pdf

[4]Eugene Kuleshov. Using the ASM framework to implement common Java bytecode transformation patterns[J].http://asm.ow2.org/current/asm-transformations.pdf

猜你喜欢
代码定义成员
主编及编委会成员简介
主编及编委会成员简介
主编及编委会成员简介
主编及编委会成员简介
创世代码
创世代码
创世代码
创世代码
成功的定义
修辞学的重大定义