最近的工作重心在为项目补齐单元测试,因此需要使用例如Mockito/PowerMock/EasyMock等工具进行mock来构造测试场景,在使用PowerMock的过程就遇到了神奇的问题

PowerMock

在mock static/final方法时,PowerMock是上古的强力工具(相对于更加简单强大的Mockito),所以在进行技术选型时,第一时间就对PowerMock进行了测试,mock操作简单效果也符合预期:

@RunWith(PowerMockRunner.class)
@PrepareForTest(StaticTestClass.class)
public class PowerMockTest {

    @Test
    public void staticMethodMockTest() throws IOException {
        String expectedData = "Good Night";
        PowerMock.mockStatic(StaticTestClass.class);
        EasyMock.expect(StaticTestClass.say()).andReturn(expectedData).anyTimes();
        PowerMock.replayAll();
        Assert.assertEquals(expectedData, StaticTestClass.say());
    }

    public static class StaticTestClass{
        public static String say(){
            return "Hello World";
        }
    }

}

但是在测试HDFS时,却遇到了意外:HDFS默认会使用启动用户的principal作为当前的fileSystemOwner,但是在PowerMock的场景中,却无法成功获取启动用户的principal,导致HDFS启动失败。

经过debug跟踪,发现最终的原因是UserGroupInformation尝试通过principal的Class类型来过滤JVM已经持有的principal来获取操作系统的用户principal(org.apache.hadoop.security.UserGroupInformation):

   // use the OS user
      if (user == null) {
        user = getCanonicalUser(OS_PRINCIPAL_CLASS);
        if (LOG.isDebugEnabled()) {
          LOG.debug("using local user:"+user);
        }
      }

其中这个OS_PRINCIPAL_CLASS会根据操作系统进行适配,例如在使用Oracle JDK时如果Windows为com.sun.security.auth.NTUserPrincipal而在Linux下为com.sun.security.auth.NTUserPrincipal,然而在过滤过程中,JVM持有的principal和当前运行时的principal的class却由于不同的ClassLoader创建,虽然他们的包名类名都完全一样,导致无法正常适配。

ClassLoader

为什么同样的包名和类名,却由不同的ClassLoader来加载呢?

PowerMockRunner

带着疑问我们找来PowerMock的文档和源码,既然是因为引入PowerMock才出现问题,肯定是PowerMock做了什么特别的事情,在一番探索之下,果然找到了罪魁祸首:PowerMock对于静态方法的mock是通过自定义的ClassLoader来实现的,当特定的需要mock的类加载时,对该类的字节码进行改造以提供能够动态修改的插槽,后续再通过api在运行时将需要mock的内容填入插槽以达到特定的mock效果

其中涉及到几个核心的对象:

  • PowerMockRunner:依托于Junit的Runner机制,在测试用例的发现、编排的过程中识别是否需要通过自己的ClassLoader进行对类的改造
  • MockClassLoader:PowerMock自定的ClassLoader,通过注解或者其他机制来标记哪些类会通过这个ClassLoader进行改造,其核心功能主要有三部分构成
    • 判断是否需要进行mock改造
    • 中断正常的双亲委派,方便后续由自己进行Class的mock和加载
    • 如果需要自己进行mock和加载,通过字节码增强来改造Class,最后通过native的defineClass方法将改造后的字节码加载为Class
  • MockTransformer:具体的改造方式,例如移除类的final标记、提供静态方法的修改插槽、将所有的构造方法都标记为public等

而我们出现问题的点就是这里被PowerMock中断的ClassLoader双亲委派机制,导致mock后的类在加载其他类时只能从MockClassLoader加载,而特定的类被MockClassLoader和JVM默认的ClassLoader分别加载和实例化,最终无法进行正确适配

双亲委派机制

上面说到中断双亲委派,那为什么中断双亲委派会有如此大的影响呢?这就要从ClassLoader的类型以及类是具体如何从ClassLoader加载说起了,首先JVM的ClassLoader的分类如图:

ClassLoader

BootstrapClassLoader和ExtClassLoader都是JVM本身的核心ClassLoader并且优先级极高,主要是加载JVM维持自己运行时的Class,而我们业务开发的类实际都是AppClassLoader进行加载,当然我们也可以定制我们自己的ClassLoader。

当底层的ClassLoader需要加载一个Class时,ClassLoader首先会查询自己是否加载过这个Class,如果有就直接返回,如果没有则会向自己的上层ClassLoader进行查询,如果上层ClassLoader有加载记录则返回Class否则上层的ClassPath也会做同样的操作向自己的上层ClassLoader查询,一直到顶层ClassLoader如果自身没有则返回null,最终结果被一层层的传递直到返回给最初的ClassLoader。如果所有的ClassLoader都没有加载过这个Class,底层ClassLoader就会自己加载这个Class并返回。这种父级优先的类加载模式就叫做Parents Delegation Model,可能是翻译的问题,在中文就变成了大家所熟知的双亲委派

双亲委派很好的解决了类的重复加载问题(如果类可以重复加载,则会出现两个对象进行比较时明明是同样的包名和类名但是他们却不是同样的Class无法进行比较的情况,参考上文所描述的PowerMock中发生的问题),保证了Class的全局唯一同时也就保证了应用上下文的统一(应用的上下文往往是通过基于对象或者全局静态类的工厂来维护,例如经典的Spring的BeanFactory/ApplicationContext),同时系统核心类也只能通过Bootstrap/Ext ClassLoader进行加载,避免JVM的核心类被轻易篡改;但是有时候我们也需要故意的中断双亲委派去适配一些特定的场景,最经典的就是tomcat的多应用部署:

Tomcat_ClassLoader

Tomcat通过使用不同的ClassLoader并且打破双亲委派最终实现了多个应用的隔离部署,基本能够获得和C#的AppDomain类似的特性,类似的还有阿里开源的 SOFAArk,也是通过类似的机制实现了类隔离和应用/模块的隔离部署

双亲委派机制在保护Java核心API的同时,也并不是没有付出成本的,其中最核心的就是顶层ClassLoader由于受到自身约束(例如ExtClassLoader只会加载rt.jar中的内容)无法加载底层ClassLoader加载的类(当一个对象需要获取某些类依赖时,会使用当前对象的ClassLoader,例如ExtClassLoader加载的类在需要其他类依赖时,也会使用ExtClassLoader进行加载,如果自身没有加载也只会向上询问BootstrapClassLoader,无法加载到应用级别的类)最终抛出ClassNotFound的异常,这个在做一些底层优化的时候就只能使用JVM本身的机制(例如根据包名、类名等进行过滤)来进行增强,能够使用的工具链也都会受到限制(因为他们没有被ClassLoader被加载)

Mockito

既然PowerMock在进行静态方法Mock的时候出现了问题,那新生代的工具例如Mockito是否也有类似的问题呢?带着这个疑问,我们对Mockito也进行了测试,Mockito的用法和PowerMock/EasyMock类似,以下为对一个静态方法进行mock:

  @Test
    public void staticMockTest() throws IOException {
        //build mock
        MockedStatic mockedStatic = Mockito.mockStatic(DelegateObject.class);
        //build action
        Mockito.when(DelegateObject.staticTest()).thenReturn("test");
        //assert
        Assert.assertEquals("mocked started", "test", DelegateObject.staticTest());
        //stop mock
        mockedStatic.close();
        //assert
        Assert.assertNotEquals("mocked stopped", "test", DelegateObject.staticTest());
    }

值得关注的是Mockito并不需要依赖于特定的Junit Runner,而且提供了可以自主进行mock中断的特性让mock变得更加灵活,并且在测试中也没有出现类似在使用PowerMock中出现的类的问题,不仅让人好奇Mockito是依托于什么机制实现的针对静态方法的mock的呢?

虽然官方文档没有详细描述,但是通过代码跟踪很快就能定位到Mockito的核心机制是通过org.mockito.plugins.MockMaker的SPI调用ByteBuddy对类进行字节码修改,最后通过基于JVMTI的Instrumentation机制对已经加载到JVM的类进行运行时的增强将修改后的字节码生效

基于JVMTI的代码增强机制

上文在描述Mockito的实现机制有说到JVMTI,那这个神奇的JVMTI到底是什么呢?具体可以参考 官方文档(基于JDK8),简单描述就是JVMTI是JVM Tool Interface的缩写,是JVM本身提供的针对JVM自身的一整套native编程接口,类似于JVM的超级管理员权限,可以监控、管理甚至控制JVM中的各项顶层资源:

  • 内存管理,包括直接申请和回收内存
  • 线程管理,包括线程的启动、暂停甚至直接中断停止
  • 堆栈管理,包括直接回滚堆栈甚至强行让方法停止执行并返回特定的结果
  • 堆管理,直接在内存级别进行堆的遍历查询
  • 以及其他包括直接设置类的成员变量、设置断点、抛出异常或者捕获异常等各种顶级权限、操作

JVMTI在本身提供c/c++的接口回调(通过配置启动参数-agentlib)的基础上为了方便Java开发将其中关于代码增强的部分通过jni进行了Java封装,这部分就是现在的java.lang.instrument.Instrumentation接口(需要通过-javaagent参数引入jar在jar的main方法中会被作为参数传入),开发者通过Instrumentation可以进行诸如修改类的字节码、额外引入外部jar包等功能。

ByteBuddy的Agent工具会在运行时生成一个临时的jar,再通过另起一个进程通过VirtualMachine的Attach机制通知JVM加载ByteBuddy生成的临时jar,最终在临时jar中获得Instrumentation工具(参考ByteBuddy源码net.bytebuddy.agent.ByteBuddyAgent#install),此后通过这个Instrumentation对象获取了对于JVM中运行时类的控制权,再结合ByteBuddy的字节码工具进行字节码修改,最终达到运行时增强Class的目的

通过Instrumentation进行增强的工具其实很多,其中就包括大名鼎鼎的阿里开源工具 Arthas,通过这个工具,可以很简单的解决如下问题:

  • 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到JVM的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?
  • 怎样直接从JVM内查找某个类的实例?

需要注意的是Arthas如果是通过redefine增强的类,其增强效果会跟随JVM的生命周期,所以需要谨慎使用

结语

通过在测试用例编写过程中对PowerMock和Mockito的学习,基本见证了对于类增强的两种不同的实现方法的理解:

  • 通过自定义ClassLoader在Class被define之前修改其字节码,同时通过切断双亲继承构造一个小的上下文进行测试,但是如果有外部的重依赖可能就会出现问题
  • 使用ByteBuddy进行简单的字节码修改(类似的工具还有cglib,但是ByteBuddy更加简单强大),通过JVMTI的Instrumentation机制将修改在运行时生效,更加简单灵活