徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为

徒手撸一个Mock框架(一)——如何创建一个mock对象
徒手撸一个Mock框架(二)——如何创建final类的代理

本文代码

徒手撸一个Mock框架(二)——如何创建final类的代理中,我们已经知道,为了mock一个final类对象,我们需要自定义一个ClassLoader。我们利用这个ClassLoader可以在读入.class文件二进制流的时候,使用字节码操作工具ASM去除类定义中的final标记位。

然而在最后我们的类加载器出现了一个小问题:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

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方法,但是实际上只是做了一些安全方面的工作,最终的工作都是委托给父类的实现来完成的。

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

AppClassLoader的父类是BuildInClassLoader,其loadClass方法的核心逻辑在loadClassOrNull

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

该实现主要步骤就是:

  1. 先检查是否已经加载了,即调用findLoadedClass(cn),这是一个缓存机制;
  2. 检查该类所在的module是否被加载了,这是在java9引入module之后出来的。如果找到了module,那么就用这个module来加载;
  3. 委托给父加载器。这就是双亲委托模型的关键步骤;
  4. 尝试自己加载;

所以,FinalObject这个类会被加载很显然是因为它满足了两个条件:

  1. findLoadedClass(cn)调用中,返回null了。这很显然,因为我们自定义的加载器肯定没有把加载的类塞到AppClassLoader的缓存里面。
  2. 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

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

这幅图揭示了这种关系。ClassA就相当于我们的StupidMockClassLoaderTestClassB就相当于FinalObject。不同类加载器加载的类,在JVM层面上就是两个类。

这样一来,依据这种传递关系,我们需要找到触发加载StupidMockClassLoaderTest的类,再找到触发加载这个类的类……一直回溯,直到找到最顶级的入口。

JUnit的顶级入口

因为我们这个StupidMock是给单测用的,所以实际上所谓的顶级入口,也就是单测的入口。考虑到单测框架有很多,我们这里只挑选junit4来做示例。毕竟,这个会了,别的单测框架,也就是依葫芦画瓢的事情。

对于tomcat之类的框架来说,顶级入口也就是启动入口。

junit里面,提供了这种顶级入口,即Runner的概念。简单来说,一个runner就是一个“容器”,或者说是“上下文”,或者说“enhancer”。开发人员可以在runner里面随心所欲扩展各种奇怪的逻辑。

本质上来说,如果我们没有自定义runner,那么单测就会运行在junit内置的几个runner之上。

我们要做的就是实现一个自己的runner,然后在runner里面使用自定义的classloader来加载所有测试类。

其大概用法看起来是这样的:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

现在我们就要考虑一下这个
StupidMockJunit4Runner该怎么实现了。

StupidMockJunit4Runner

StupidMockJunit4Runner的父类有一个接收Class对象的构造函数,看上去就是最佳的切入点。

我们可以在调用super(klass)的时候不再传入原始的Class对象,而是传入我们修改后的类。

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

按照我们前面的分析,这样大概就可以。然而现实是残酷的,运行测试我们得到的是java.lang.Exception: No runnable methods

这就是神奇了,因为我们明明在测试方法上加了Test注解。

为什么呢?

要记住,JVM类型匹配是连ClassLoader一起考虑进去的。显然,StupidMockJunit4Runner是被AppClassLoader加载的,我们可以通过断点进去看:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

而我们的StupidMockClassLoaderTest却是被我们的StupidClassLoader加载的。StupidMockJunit4Runner在查找测试方法的时候,找的是被AppClassLoader加载的Test注解标记的方法。

StupidMockClassLoaderTest里面的方法是被StupidMockClassLoader加载的Test注解所标记的。这就是出现java.lang.Exception: No runnable methods的原因。

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

那么,我们该怎么解决这个问题呢?
答案是我们依旧使用自定义的类加载器,但是这个加载器不再是不分青红皂白全部自己加载。我们给它加上一条规则:如果是需要被处理的,那么我们就使用自定义的加载器进行加载,否则我们就使用系统预定义的加载器进行加载

改进版StupidMockClassLoader

为了继续下去,我先把测试贴出来:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

我们的目标是使得mockFinal能够通过。

实现的关键是,如何断定一个类需要被自定义加载器加载,还是委托给系统加载器加载?

首先我们可以肯定的是,被测试的那个类,在这里也就是StupidMockTest肯定要被自定义的加载器StupidMockClassLoader加载。不然的话,系统就会使用AppClassLoader来加载。这就造成了StupidMockTest里面的类,都会被AppClassLoader所加载。这意味着,整个过程都不会经过我们的自定义的加载器。

其次我们还可以确定的是,FinalObject这个类必然要被StupidMockClassLoader所加载,毕竟我们需要需改这个类定义。

最后,我们可以断定的是,系统类——即JDK里面的那些,Junit的类都不能被StupidMockClassLoader所加载。暂时我们也无法将所有的不需要被夹在的类都列出来,不过我们的目标只是demo一下,所以可以先随便写一点。

StupidMockClassLoader里面,我们很容易知道StupidMockTest这个类是顶级类,因为构造函数里面传递进来的就是这个类。那么问题是,我们怎么知道FinalObject这个类需要被修改呢?

答案是,我们需要用户告诉我们。因此我们定义一个注解PrepareForTest。这个注解的概念就是从PowerMock里面借鉴来的,两者的语义实际上差不多。

所以我们的单测形如:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

有了这个注解之后,我们需要进一步区分。被StupidMockClassLoader加载的类,也要分成两类:一类是需要被修改的,比如我们mockfinal类;另外一类是不需要修改的。

StupidMockClassLoader最终实现

最终我们的StupidMockClassLoader是:

《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》
《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》
《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》
《徒手撸一个Mock框架(三)—— JUnit4Runner+ClassLoader=为所欲为》

整体实现逻辑非常简单:

  1. 在构造函数里面找到PrepareForTest注解,target的值就是需要被自定义加载器加载的类;
  2. 我们选择重写loadClass(name, resolve)方法,以破坏双亲委托模型;
  3. loadClass方法会尝试从缓存里面获取,获取不到则判断是否需要被自定义加载器加载,如果不需要,则委托给parent加载;
  4. 如果需要被自己加载,那么判断是否需要修改类,调用loadModifiedClass或者loadUnmodifiedClass;
  5. parent在初始化的时候,被设定为Thread.currentThread().getContextClassLoader()。在这里是不会有问题的;
  6. 使用一个ConcurrentMap结合SoftReference来做自身加载类的缓存。可以确保的是,如果一个类没有任何实例,那么只会有一个这个MapSoftReference指向它,这可以保证GC能够正确处理类;
  7. ALWAYS_IGNORE_PACKAGE定义的是需要被忽略的类缩在的包的前缀,这些类应该被系统所加载,就不需要碰了;

存在问题

这个实现,说白了就是一个玩具实现。在真正用于生产环境的时候会有很多的问题;

  1. 在多线程环境下,或者使用的框架自定义了ClassLoader,将parent设置为Thread.currentThread().getContextClassLoader()可能会有问题;
  2. 每个单测类都会创建一个StupidMockClassLoader实例,在并行测试的时候可能出现创建了大量ClassLoader实例,从而快速达到metaspace设置的上限;
  3. 不支持SecurityManagerProtectionDomain,安全方面是一个大问题;
  4. 那些被忽略的包里面的类,我们都没有修改,意味着无法mock其中的final类;

最后说的是,到这一步,实际上mock框架最关键的部分已经出来了。采用自定义的RunnerClassLoader我们几乎可以做任何事情了,剩下不能做的事情,后面会在jvmagent里面尝试解决一下。

下一篇,我们将开始讨论when...then,即方法的mock

    原文作者:flycash
    原文地址: https://www.jianshu.com/p/fb0a2f268b3e
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞