徒手撸一个Mock框架(一)——如何创建一个mock对象
徒手撸一个Mock框架(二)——如何创建final类的代理
在徒手撸一个Mock框架(二)——如何创建final类的代理中,我们已经知道,为了mock一个final
类对象,我们需要自定义一个ClassLoader
。我们利用这个ClassLoader
可以在读入.class
文件二进制流的时候,使用字节码操作工具ASM
去除类定义中的final
标记位。
然而在最后我们的类加载器出现了一个小问题:
java.lang.ClassCastException: class cn.com.flycash.stupidmock.testobj.FinalObject cannot be cast to class cn.com.flycash.stupidmock.testobj.FinalObject (cn.com.flycash.stupidmock.testobj.FinalObject is in unnamed module of loader cn.com.flycash.stupidmock.classloader.StupidMockClassLoader @50de0926; cn.com.flycash.stupidmock.testobj.FinalObject is in unnamed module of loader 'app')
我们自定义的类加载器加载了的类,又被AppClassLoader
加载器重新加载了一遍。因此导致了ClassCastException
。
今天我们就要解决这个问题。
两次加载的原因
首先我要解答一下为什么AppClassLoader
会再一次加载我们已经加载过的类。
AppClassLoader
虽然重写了loadClass
方法,但是实际上只是做了一些安全方面的工作,最终的工作都是委托给父类的实现来完成的。
AppClassLoader
的父类是BuildInClassLoader
,其loadClass
方法的核心逻辑在loadClassOrNull
:
该实现主要步骤就是:
- 先检查是否已经加载了,即调用
findLoadedClass(cn)
,这是一个缓存机制; - 检查该类所在的
module
是否被加载了,这是在java9引入module
之后出来的。如果找到了module
,那么就用这个module
来加载; - 委托给父加载器。这就是双亲委托模型的关键步骤;
- 尝试自己加载;
所以,FinalObject
这个类会被加载很显然是因为它满足了两个条件:
- 在
findLoadedClass(cn)
调用中,返回null了。这很显然,因为我们自定义的加载器肯定没有把加载的类塞到AppClassLoader
的缓存里面。 -
FinalObject
的.class
文件处于AppClassLoader
的类路径里面。这也不难想到,因为在默认情况下,IDE会把编译后的文件都加入类路径里面。
但是这只解释了为什么AppClassLoader
会加载,但是没有解释,为什么会触发这个类加载器来加载FinalObject
。
为何会触发AppClassLoader加载类?
很多人都没有思考过这么一个问题,JVM依据什么来决定使用哪个类加载器。
这并不是说双亲委托模型里面,JVM如何决定;而是指,当我有好几个类加载器的时候,如果它们不构成双亲委托模型,那么JVM该如何决定使用哪个类加载器?
就如同我们实现的StupidMockClassLoader
。它就是破坏了双亲委托模型。那么JVM什么情况下会使用StupidClassLoader
来加载一个类,而又在什么情况下使用预定义的——如AppClassLoader
——加载器来加载类呢?
答案也是很简单的:JVM会使用当前类加载器来加载依赖的类。
即,如果A里面创建了一个B的对象,那么加载B的时候,就会使用加载A的类加载器。
所以,我们使用Class.forName
来加载FinalObject
类的时候,并没有改变当前类StupidMockClassLoaderTest
的类加载器,它依旧是AppClassLoader
。
当执行到FinalObject object = (FinalObject) finalObjectClass.getConstructor().newInstance();
的时候,JVM就会使用AppClassLoader
来加载FinalObject
。
这幅图揭示了这种关系。ClassA
就相当于我们的StupidMockClassLoaderTest
,ClassB
就相当于FinalObject
。不同类加载器加载的类,在JVM层面上就是两个类。
这样一来,依据这种传递关系,我们需要找到触发加载StupidMockClassLoaderTest
的类,再找到触发加载这个类的类……一直回溯,直到找到最顶级的入口。
JUnit的顶级入口
因为我们这个StupidMock
是给单测用的,所以实际上所谓的顶级入口,也就是单测的入口。考虑到单测框架有很多,我们这里只挑选junit4
来做示例。毕竟,这个会了,别的单测框架,也就是依葫芦画瓢的事情。
对于
tomcat
之类的框架来说,顶级入口也就是启动入口。
在junit
里面,提供了这种顶级入口,即Runner
的概念。简单来说,一个runner
就是一个“容器”,或者说是“上下文”,或者说“enhancer”。开发人员可以在runner
里面随心所欲扩展各种奇怪的逻辑。
本质上来说,如果我们没有自定义runner
,那么单测就会运行在junit
内置的几个runner
之上。
我们要做的就是实现一个自己的runner
,然后在runner
里面使用自定义的classloader
来加载所有测试类。
其大概用法看起来是这样的:
现在我们就要考虑一下这个
StupidMockJunit4Runner
该怎么实现了。
StupidMockJunit4Runner
StupidMockJunit4Runner
的父类有一个接收Class
对象的构造函数,看上去就是最佳的切入点。
我们可以在调用super(klass)
的时候不再传入原始的Class
对象,而是传入我们修改后的类。
按照我们前面的分析,这样大概就可以。然而现实是残酷的,运行测试我们得到的是java.lang.Exception: No runnable methods
。
这就是神奇了,因为我们明明在测试方法上加了Test
注解。
为什么呢?
要记住,JVM类型匹配是连ClassLoader
一起考虑进去的。显然,StupidMockJunit4Runner
是被AppClassLoader
加载的,我们可以通过断点进去看:
而我们的StupidMockClassLoaderTest
却是被我们的StupidClassLoader
加载的。StupidMockJunit4Runner
在查找测试方法的时候,找的是被AppClassLoader
加载的Test
注解标记的方法。
而StupidMockClassLoaderTest
里面的方法是被StupidMockClassLoader
加载的Test
注解所标记的。这就是出现java.lang.Exception: No runnable methods
的原因。
那么,我们该怎么解决这个问题呢?
答案是我们依旧使用自定义的类加载器,但是这个加载器不再是不分青红皂白全部自己加载。我们给它加上一条规则:如果是需要被处理的,那么我们就使用自定义的加载器进行加载,否则我们就使用系统预定义的加载器进行加载。
改进版StupidMockClassLoader
为了继续下去,我先把测试贴出来:
我们的目标是使得mockFinal
能够通过。
实现的关键是,如何断定一个类需要被自定义加载器加载,还是委托给系统加载器加载?
首先我们可以肯定的是,被测试的那个类,在这里也就是StupidMockTest
肯定要被自定义的加载器StupidMockClassLoader
加载。不然的话,系统就会使用AppClassLoader
来加载。这就造成了StupidMockTest
里面的类,都会被AppClassLoader
所加载。这意味着,整个过程都不会经过我们的自定义的加载器。
其次我们还可以确定的是,FinalObject
这个类必然要被StupidMockClassLoader
所加载,毕竟我们需要需改这个类定义。
最后,我们可以断定的是,系统类——即JDK里面的那些,Junit
的类都不能被StupidMockClassLoader
所加载。暂时我们也无法将所有的不需要被夹在的类都列出来,不过我们的目标只是demo一下,所以可以先随便写一点。
在StupidMockClassLoader
里面,我们很容易知道StupidMockTest
这个类是顶级类,因为构造函数里面传递进来的就是这个类。那么问题是,我们怎么知道FinalObject
这个类需要被修改呢?
答案是,我们需要用户告诉我们。因此我们定义一个注解PrepareForTest
。这个注解的概念就是从PowerMock
里面借鉴来的,两者的语义实际上差不多。
所以我们的单测形如:
有了这个注解之后,我们需要进一步区分。被StupidMockClassLoader
加载的类,也要分成两类:一类是需要被修改的,比如我们mock
的final
类;另外一类是不需要修改的。
StupidMockClassLoader最终实现
最终我们的StupidMockClassLoader
是:
整体实现逻辑非常简单:
- 在构造函数里面找到
PrepareForTest
注解,target
的值就是需要被自定义加载器加载的类; - 我们选择重写
loadClass(name, resolve)
方法,以破坏双亲委托模型; -
loadClass
方法会尝试从缓存里面获取,获取不到则判断是否需要被自定义加载器加载,如果不需要,则委托给parent
加载; - 如果需要被自己加载,那么判断是否需要修改类,调用
loadModifiedClass
或者loadUnmodifiedClass
; -
parent
在初始化的时候,被设定为Thread.currentThread().getContextClassLoader()
。在这里是不会有问题的; - 使用一个
ConcurrentMap
结合SoftReference
来做自身加载类的缓存。可以确保的是,如果一个类没有任何实例,那么只会有一个这个Map
的SoftReference
指向它,这可以保证GC能够正确处理类; -
ALWAYS_IGNORE_PACKAGE
定义的是需要被忽略的类缩在的包的前缀,这些类应该被系统所加载,就不需要碰了;
存在问题
这个实现,说白了就是一个玩具实现。在真正用于生产环境的时候会有很多的问题;
- 在多线程环境下,或者使用的框架自定义了
ClassLoader
,将parent
设置为Thread.currentThread().getContextClassLoader()
可能会有问题; - 每个单测类都会创建一个
StupidMockClassLoader
实例,在并行测试的时候可能出现创建了大量ClassLoader
实例,从而快速达到metaspace
设置的上限; - 不支持
SecurityManager
和ProtectionDomain
,安全方面是一个大问题; - 那些被忽略的包里面的类,我们都没有修改,意味着无法
mock
其中的final
类;
最后说的是,到这一步,实际上mock
框架最关键的部分已经出来了。采用自定义的Runner
和ClassLoader
我们几乎可以做任何事情了,剩下不能做的事情,后面会在jvmagent
里面尝试解决一下。
下一篇,我们将开始讨论when...then
,即方法的mock
。