使用 javassist 生成类 - javassist 为什么要把方法的创建和添加分开

先来看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testGenerateFirstClass() throws Exception {
// 获取类池,类池有两个主要用途:(1) 获取已知类 (2) 制造新类
ClassPool pool = ClassPool.getDefault();
// 制造新类,需要提供全类名
CtClass ctClass = pool.makeClass("com.powernode.bank.dao.impl.AccountDaoImpl");
// 制造方法,第一个参数是方法代码字符串,第二个参数是该方法所属的类
String methodCode = "public void insert() { System.out.println(123); }";
CtMethod ctMethod = CtMethod.make(methodCode, ctClass);
// 将方法添加到类中,这一步是必需的
ctClass.addMethod(ctMethod);
// 在内存中生成 Class:生成制造类的字节码,并将类字节码装载到 JVM 中,初始化该类,
// 完成静态代码块和静态字段的初始化,最后返回 JVM 中对应的 Class 对象
Class<?> tmp = ctClass.toClass();
}

主要看其中的这两行代码:

1
2
CtMethod ctMethod = CtMethod.make(methodCode, ctClass);
ctClass.addMethod(ctMethod);

我的疑惑是,既然在制造方法的时候,就需要提供 ctClass,那后面的 ctClass.addMethod(ctMethod) 为什么不直接写到 CtMethod.make 方法中呢?

网上基本找不到相关的问题,问 ChatGPT / New Bing / Glaude 给我的回复大都是:

  • 分开更灵活,通过分离这些步骤,开发人员可以在将方法最终添加到类之前,根据需要对其执行额外的修改。

这样的说法看起来很有道理,但是一测试就发现:方法 ctMethod 添加到 ctClass 以后,仍然可以对 ctMethod 进行修改操作。这样的话,所谓的更灵活的说法就没有说服力了。


在研究这个问题前,这里先做三个测试,通过测试来了解创建方法和添加方法的一些细节:

测试一:CtMethod.make("method_code", ctClass) 制造出的方法 ctMethod 会与 ctClass 建立关联,后续 ctMethod 只能被添加到 ctClass 中,而不能添加到其他类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void testAddOtherCtMethod() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtClass ctClassB = pool.makeClass("com.powernode.pojo.B");

CtMethod ctMethodForA = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
// 将 ctMethodForA 方法添加到 ctClassA 中
ctClassA.addMethod(ctMethodForA);
// 将 ctMethodForA 方法添加到 ctClassB 中
// 抛出异常:javassist.CannotCompileException: bad declaring class
ctClassB.addMethod(ctMethodForA);
}

测试二:ctClass.addMethod(ctMethod) 是必需的,这一步才是真正将方法 ctMethod 添加到类 ctClass 中,CtMethod.make 只是建立了二者的关联,但并没有真正的添加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testDoNotAddMethod() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtMethod ctMethodForA = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
// 不添加方法到类中
// ctClassA.addMethod(ctMethodForA);
Class<?> clazzA = ctClassA.toClass();

Object objA = clazzA.getDeclaredConstructor().newInstance();
// 抛出异常 找不到方法:java.lang.NoSuchMethodException: com.powernode.pojo.A.func1()
Method func1 = clazzA.getDeclaredMethod("func1");
func1.invoke(objA);
}

测试三:ctClass.addMethod(ctMethod) 会进行检查 ctMethod 是否能够添加到 ctClass 中!

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testAddConflictMethod() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtMethod ctFunc1 = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
ctClassA.addMethod(ctFunc1);
// 依然可以创建同名 func1 方法并与 ctClassA 关联
CtMethod ctStillFunc1 = CtMethod.make("public void func1() { System.out.println(\"still func1\"); }", ctClassA);
// 但在添加时,会检查到重复方法
// 抛出异常:javassist.bytecode.DuplicateMemberException: duplicate method: func1 in com.powernode.pojo.A
ctClassA.addMethod(ctStillFunc1);
}

通过以上三个测试,我们清楚了:

  • CtMethod ctMethod = CtMethod.make(methodCode, ctClass) 只是建立了 ctMethodctClass 的关联,并没有将 ctMethod 添加到 ctClass 中。
  • ctClass.addMethod(ctMethod) 是必需的,这一步会检查 ctMethod 是否能够添加到 ctClass 中,如果可以,才真正将 ctMethod 添加到 ctClass 中。

下面我们再看两个测试,这两个测试聚焦于方法的修改操作,探究修改方法会带来什么影响:

测试四:下面将两个不同名的方法添加到类中,然后修改其中一个方法的方法名,使其与另外一个方法保持同名,修改操作不会立刻抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testModifyMethodToConflict() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtMethod ctFunc1 = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
ctClassA.addMethod(ctFunc1);
CtMethod ctFunc2 = CtMethod.make("public void func2() { System.out.println(\"func2\"); }", ctClassA);
ctClassA.addMethod(ctFunc2);

ctFunc2.setName("func1");

// 抛出异常 编译失败:javassist.CannotCompileException: by java.lang.reflect.InvocationTargetException
// 查看异常堆栈 检查到了重复方法名:Caused by: java.lang.ClassFormatError: Duplicate method name "func1" with signature "()V" in class file com/powernode/pojo/A
Class<?> clazzA = ctClassA.toClass();
}

可以看到,相比测试三而言,测试四的重复方法异常要等到生成类 ctClassA.toClass() 才会暴露出来。

测试五:下面包含两个方法,其中一个方法 ctFunc2 内部调用了另外一个方法 ctFunc1,在两个方法都添加到类中以后,修改被调用方法 ctFunc1 的函数名,修改操作不会立刻抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void testMethodCallOtherMethod() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtMethod ctFunc1 = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
ctClassA.addMethod(ctFunc1);
// 制造方法时,会利用传入的 ctClassA 检查要调用的 func1() 是否已存在
CtMethod ctFunc2 = CtMethod.make("public void func2() { func1(); }", ctClassA);
ctClassA.addMethod(ctFunc2);

ctFunc1.setName("func123");
Class<?> clazzA = ctClassA.toClass();

Object objA = clazzA.getDeclaredConstructor().newInstance();
Method func2 = clazzA.getDeclaredMethod("func2");
// 抛出异常 调用异常:java.lang.reflect.InvocationTargetException
// 查看异常堆栈 找不到方法:Caused by: java.lang.NoSuchMethodError: 'void com.powernode.pojo.A.func1()'
func2.invoke(objA);
}

可以看到,相比测试四而言,测试五的异常要等到方法调用执行时 func2.invoke(objA) 才会暴露出来。

这里诡异的是,异常居然没有在生成类的时候就发现!
如果我们将这个要制造的类写成代码,那么在 ctClassA.toClass() 之前,其内容应该如下:

1
2
3
4
5
6
7
8
9
10
package com.powernode.pojo.A;

public class A {
public void func123() {
System.out.println("func1");
}
public void func2() {
func1(); // 这一行编译就应该报错
}
}

很明显这样的代码不应该能够通过编译,可是在生成类 ctClassA.toClass() 的时候却没有抛出异常!

通过测试四和测试五,不难意识到:虽然 javassist 允许方法被添加到类中以后仍然可以进行修改,但是这样做,可能会带来各种各样奇怪的问题。

最佳实践是:创建方法且修改方法完毕后,再将方法添加到类中,且方法一旦添加到类中后,就不要进行修改!

另外,注意在创建方法 ctFun2 的时候,由于 ctFun2 的方法体中调用了 fun1,因此在创建时,CtMethod.make("...", ctClassA) 方法其实会检查 ctClassA 中是否已经定义了 fun1,这里反映出传 ctClassA 作为第二个参数的另外一个作用,检查新添加到类中的方法是否合法。


上面的测试四和测试五中,我们在方法添加到类中后,又对方法进行了修改,但修改方法的位置却没有立刻报错。

猜测,是不是修改方法,本身不会做任何检查呢?其实并不是所有修改方法,都不做任何检查,比如 setBody 方法,底层会进行方法体的编译检查!

测试六:创建方法后,修改方法体。

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testModifyMethodBody() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
CtMethod ctFunc1 = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
ctClassA.addMethod(ctFunc1);
// 制造方法时,会利用 ctClassA 检查 func1 是否已存在
CtMethod ctFunc2 = CtMethod.make("public void func2() { func1(); }", ctClassA);
// 修改方法体,调用一个不存在的方法
// 抛出编译异常:javassist.CannotCompileException: [source error] func3() not found in com.powernode.pojo.A
ctFunc2.setBody("{ func3(); }");
}

但是 setName 方法,底层似乎确实没做任何检查,甚至连标识符命名的检查都没有。

测试七:故意为方法设置了不满足 Java 标识符命名规则的方法名,但却可以正常调用执行!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testMethodName() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClassA = pool.makeClass("com.powernode.pojo.A");
// 创建方法时会检查方法名的标识符命名是否满足要求,底层 javassist 进行了编译
CtMethod ctFunc1 = CtMethod.make("public void func1() { System.out.println(\"func1\"); }", ctClassA);
ctFunc1.setName("####");
ctClassA.addMethod(ctFunc1);

Class<?> clazzA = ctClassA.toClass();
Object objA = clazzA.getDeclaredConstructor().newInstance();
Method badName = clazzA.getDeclaredMethod("####");
// 正常调用,输出 "func1"
badName.invoke(objA);
}

javassist 中在 CtMethod.make 时会检查方法名的标识符命名是否满足要求,但可以通过 setName 绕过。


现在回到最初的问题,为什么 javassist 要把方法的创建和方法的添加分两步实现?为什么不在 CtMethod.make 中把 ctClass.addMethod(ctMethod) 干脆也一并做了呢?

最直观地,通过测试四和测试五以后,不难发现,如果创建方法的同时就将方法添加到类中,以后就不好再去修改方法了:

  • 如果要修改的话,虽然允许修改,但是一旦修改就违反了最佳实践,可能带来各种奇奇怪怪的问题
  • 如果不修改的话,又意味着在创建方法的时候,要一次性把方法全部准备好,这就牺牲了很多的灵活性。

此外分步实现的话,代码可读性可维护性也更好,方法的完整定义肯定是包裹在方法的创建和方法的添加之间的,作用域相对明确。

这是较浅层次的理解,我认为更深层次的原因其实是设计上的考虑。

类是一个比较复杂的东西,每次做的修改可能会导致整个类最后出问题。
为了尽可能保证正确性,那么每一步的修改,都一定是要经过最严格的检查,才正式添加到类中。
这样的话,类虽然一步一步地变复杂,但是在每个时刻是完整且正确的。
另外,修改频率也应该尽可能低,一方面是少改少错,另一方面即使存在完美的检查允许高频修改,那么完美检查的代价也是不容忽视的。

javassist 既然是在制造类,那么肯定也会有如上的设计考虑。

方法正式添加到类中,这是一种对类的修改,所以它需要做最严格的检查,确保不会对类整体带来影响。
同时也需要保证类的完整性和修改频率尽可能低。如何保证?很自然地,让方法的创建和修改彻底完成后,再进行方法的添加!因此,要把方法的创建与修改,和方法添加到类中分开。

方法的创建与修改,是圈地自萌的小打小闹,发生频率较高,我们可以额外做一些部分的更聚焦于当前方法自身的小检查来保证方法的基本正确性。由于小检查的代价较低,所以即便修改频率较高,也不会带来太大的性能影响。

方法添加到类中,是正式走上台面的团队协作,我们必须做一些方法到类整体上的大检查。虽然大检查的代价较高,但是添加只做一次,是低频的,所以可以接收。

这样划分步骤以后,能够保证类是一步一步地变复杂,但每个时刻是完整且正确的。

试想一下,如果方法的创建与添加真的合并在一起了:由于方法已经在类中,此时每次修改操作,显然就都要做最严格的的检查了,这肯定至少在性能上有影响;如果没有做最严格的的操作,那就不能保证类整体的正确性。
另外,方法的修改可能是分多步进行的,这就意味着某个时刻的类,可能正处于修改的某一步,类不是完整的,这样也会带来很多设计上的复杂性,比如说可能检查需要考虑更多的东西。

其实,这样的设计思路在我们日常开发过程中也可以体现出来。
方法的创建与修改就类似于本地开发一个新模块,最多我们自己写一些单元测试测一测,保证基本是没什么问题的。
方法添加到类中,就类似于将本地的新模块放到整个系统中,此时肯定需要更系统的测试,确保没问题后,再正式上线。