ProGuard 进阶系列(四)访问者模式
在进行后面的内容分析之前,不得不讲到访问者模式,这是 GOF 23 个设计模式中最难的几个模式之一。如果能够很好的理解访问者模式, 后续源码解读会相对容易一些。本文将结合 ProGuard 的部分源码,理解分析访问者模式的用途及使用场景。
先来看定义,访问者模式是一种将对象操作算法与对象结构分离的设计模式。这句话很抽象,不是很好理解。用通俗的话来讲就是:我们在写代码的时候,一般情况下,会将对象的操作算法定义此对象内部,直接通过对象的某个方法去获取信息或进行对象操作,当有新需求变更时,就需要对此对象的代码进行修改,来满足新的需求。但在访问者模式中,会将对象结构固定下来,并且后续不在对其修改。面对新的需求时,不需要去修改对象结构,通过添加新的访问者去实现对象操作。访问者模式遵从「开闭原则」。当然,并不是所有场景都适合使用访问者模式,一般使用访问者模式的场景如下:
在来看一下访问者模式的类图:
介绍一下,类图中最重要的两个角色:
Visitor:接口或者抽象类,定义对 Element
的访问行为,visit
方法的参数为要访问的 Element
。理论上,它的方法数与要访问的 Element
个数一致。因此,访问者模式要求的类型要稳定,如果经常添加、移除 Element
,必然会导致频繁地修改 Visitor
接口,如果出现这种情况,则说明不适合使用访问者模式。
Element:元素接口或者抽象类,它定义了一个接受访问者(accept)的方法,其意义是指每一个元素都要可以被访问者访问。
看到这里,依然很抽象,但是我相信应该有一个大体的了解,为了更好的理解这个模式,下面将从 ProGuard中对 class 文件的部分操作,还原访问者模式的诞生过程。
要想更好的理解访问者模式,还是得从真实案例入手。在 ProGuard 中, 主要操作的是 Class 文件,而 Class 文件的格式非常固定,Oracle 文档中,对 Class 文件定义如下:
并且在 ProGuard 的执行过程中,可能会源源不断的增加对 ClassFile
的操作。 这就很好的对应上了使用访问者模式的两个条件。
先来思考一下,在 ProGuard 中,对 Class 文件的操作有哪些?
ProgramClass
对象中ProgramClass
对象进行修改,如修改类名、变量名、方法名、方法参数类型等ProgramClass
对象的信息写入到 Class 文件在这里,看一下从文件中读取 Class 的内容,根据定义,在 ClassFile 中,会有一个常量池,常量池中可能会有各种不同的常量, 如 IntegerConstant
、FloatConstant
、StringConstant
、Utf8Constant
等。现在要实现从 Class 文件中,读取这些信息出来。如果让你来实现,你会怎么做呢?
实现这个功能并不难,不同的人有不同的写法,我将其中一种实现方式代码贴在下面。其中,Constant
是一个接口,包含一个方法 readFromResource
abstract class Constant {
abstract void readFromResource(DataInput dataInput) throws Exception;
}
class IntegerConstant extends Constant {
public int u4value;
@Override
void readFromResource(DataInput dataInput) throws Exception {
this.u4value = dataInput.readInt();
}
}
class Utf8Constant extends Constant {
public byte[] bytes;
@Override
void readFromResource(DataInput dataInput) throws Exception {
int u2length = dataInput.readUnsignedShort();
System.out.println(u2length);
byte[] bytes = new byte[u2length];
dataInput.readFully(bytes);
this.bytes = bytes;
}
}
public class VisitorV1 {
public static void main(String[] args) throws Exception {
File clazzFile = new File("visit.class");
DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
for (Constant constant : listAllConstant()) {
constant.readFromResource(dataInput);
System.out.println(constant);
}
}
public static List<Constant> listAllConstant() {
List<Constant> list = new ArrayList<>();
list.add(new IntegerConstant());
list.add(new Utf8Constant());
return list;
}
}
针对这些 Constant ,我们不仅需要读取,还需要修改、写文件等逻辑。如果继续按照这个思路写下去,我们需要在添加 modify
、save
等方法,这将带来一些新的问题:
要解决这个问题,常用的解决方法就是拆分解藕,把业务操作与数据结构进行解藕,设计成独立的类。这里我们按照访问者模式的演进思路对代码进行重构,重构后代码如下:
abstract class Constant {
}
class IntegerConstant extends Constant {
public int u4value;
}
class Utf8Constant extends Constant {
public byte[] bytes;
}
class Reader {
public DataInput dataInput;
public Reader(DataInput dataInput) {
this.dataInput = dataInput;
}
public void readFromResource(IntegerConstant constant) throws Exception {
constant.u4value = dataInput.readInt();
}
public void readFromResource(Utf8Constant constant) throws Exception {
int u2length = dataInput.readUnsignedShort();
byte[] bytes = new byte[u2length];
dataInput.readFully(bytes);
constant.bytes = bytes;
}
}
public class VisitorV2 {
public static void main(String[] args) throws Exception {
File clazzFile = new File("visit.class");
DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
Reader reader = new Reader(dataInput);
for (Constant constant : listAllConstant()) {
reader.readFromResource(constant);
System.out.println(constant);
}
}
public static List<Constant> listAllConstant() {
List<Constant> list = new ArrayList<>();
list.add(new IntegerConstant());
list.add(new Utf8Constant());
return list;
}
}
这其中最关键的一点设计是, 我们将读取不同类型常量的操作,设计成了三个重载函数。众所周知,重载函数是指在同一个类中函数名相同、参数不同的一组函数。
针对重构后的代码,如果你足够细心,就会发现上面的代码,其实是无法编译通过。
reader.readFromResource(constant);
此行代码编译会报错,报错如下:
这是为什么呢?
要解释这个问题,先说一下 Java 中的多态,它是一种动态绑定,在运行时可以获取对象的具体类型,然后在运行其实际类型对应的方法。我们就是希望代码在运行的时候,能够像多态那样,在运行时能够根据参数类型去调不同的 readFromResource
方法。然而在代码实现中使用的是函数重载,它是一种静态绑定。代码在编译时并不能识别对象的具体类型,因此上面代码编译时就会出现「找不到合适的方法」这个错误。
解决办法就是访问者模式的精髓,先来看一下代码:
abstract class Constant {
public abstract void accept(Reader reader) throws Exception;
}
class IntegerConstant extends Constant {
public int u4value;
@Override
public void accept(Reader reader) throws Exception {
reader.readFromResource(this);
}
}
class Utf8Constant extends Constant {
public byte[] bytes;
@Override
public void accept(Reader reader) throws Exception {
reader.readFromResource(this);
}
}
// 与前面 Reader 一样
public class VisitorV3 {
public static void main(String[] args) throws Exception {
File clazzFile = new File("visit.class");
DataInput dataInput = new DataInputStream(new FileInputStream(clazzFile));
Reader reader = new Reader(dataInput);
for (Constant constant : listAllConstant()) {
constant.accept(reader);
System.out.println(constant);
}
}
}
在执行 constant.accept(reader)
的时候,根据 Java 的多态性,在运行时,就会调用实际类型的 accept
函数, 比如 Utf8Constant
的 accept
函数,而此时在 Utf8Constant
中会调用 Reader
的 readFromResource
方法,此处的参数 this
类型就是 Utf8Constant
,在编译时就确定了,所以会调用 readFromResource(Utf8Constant constant)
这个重载函数。
如果,你看懂了这个,就基本已经理解了访问者模式。现在,我们继续添加新的功能, 例如我需要对 Constant
进行修改或重新写入新的文件。此时,实现可以参考前面的 Reader
,实现新的 Modifier
和 Writer
,并且给这两个类分别定义二个重载方法,以实现对不同 Constant
进行操作。当然,还需要在 Constant
中添加新的 accept
方法,具体实现如下:
abstract class Constant {
public abstract void accept(Reader reader) throws Exception;
public void accept(Writer writer) throws Exception;
}
class IntegerConstant extends Constant {
public int u4value;
@Override
public void accept(Reader reader) throws Exception {
reader.readFromResource(this);
}
@Override
public void accept(Writer writer) throws Exception {
writer.writeToResource(this);
}
}
// ... 省略部分代码
class Writer {
public DataOutput dataOutput;
public Writer(DataOutput dataOutput) {
this.dataOutput = dataOutput;
}
public void writeToResource(IntegerConstant constant) throws Exception {
dataOutput.writeInt(constant.u4value);
}
public void readFromResource(Utf8Constant constant) throws Exception {
dataOutput.writeShort(constant.bytes.length);
dataOutput.write(constant.bytes);
}
}
// ... 省略测试入口
不同的操作已经抽象到不同的实现类中去了,但是每一次添加新的功能,都需要去修改对应的资源文件,不仅工作量巨大,而且违反了开闭原则。针对这个问题,可以回到文件的那个类图,我们可以设计一个 Visitor
的接口,并在接口中定义访问这些不同常量的 visit
方法,按照这个思路,重构后的代码示例如下:
abstract class Constant {
public abstract void accept(Visitor visitor) throws Exception;
}
class IntegerConstant extends Constant {
public int u4value;
@Override
public void accept(Visitor visitor) throws Exception {
visitor.visit(this);
}
}
class Utf8Constant extends Constant {
public byte[] bytes;
@Override
public void accept(Visitor visitor) throws Exception {
visitor.visit(this);
}
}
interface Visitor {
void visit(IntegerConstant constant) throws Exception;
void visit(Utf8Constant constant) throws Exception;
}
class Reader implements Visitor {
public DataInput dataInput;
public Reader(DataInput dataInput) {
this.dataInput = dataInput;
}
public void visit(IntegerConstant constant) throws Exception {
constant.u4value = dataInput.readInt();
}
public void visit(Utf8Constant constant) throws Exception {
int u2length = dataInput.readUnsignedShort();
byte[] bytes = new byte[u2length];
dataInput.readFully(bytes);
constant.bytes = bytes;
}
}
class Writer implements Visitor {
public DataOutput dataOutput;
public Writer(DataOutput dataOutput) {
this.dataOutput = dataOutput;
}
@Override
public void visit(IntegerConstant constant) throws Exception {
dataOutput.writeInt(constant.u4value);
}
@Override
public void visit(Utf8Constant constant) throws Exception {
dataOutput.writeShort(constant.bytes.length);
dataOutput.write(constant.bytes);
}
}
从上面例子中代码迭代中,应该能够很好地理解访问者模式了。访问者模式在日常开发工作中,很少会用到。访问者模式实现了一个或多个操作应用到一组对象上,设计意图是解耦操作与对象本身,保持类职责单一,满足开闭原则以应对直接修改代码带来的复杂性。
因为访问者模式实现非常的不好理解,在项目中使用此模式会导致代码的可读性降低。所以在项目中是否需要使用此模式,需要谨慎评估。
最后,ASM 以及 ProGuard 中对 Class 文件操作都很好地应用了访问者模式,有兴趣可以深度理解一下其源码实现,相信有不一样的收获。