Pampas 集成 Spring developer tools 诡异 Bug 排查

为什么写这篇 Blog ?

写这篇 Blog 纯属临时起意, 因为遇见了一个坑, 居然花了几个小时在上面, 一是记录一下, 二来也可以分享出来, 让大家乐呵乐呵.

Pampas 最近在做 Hot reload, 就是热加载, 主要是来回人工重启很麻烦, 特别是一次起多个应用的时候. 虽然本文结局有点尴尬, 但也不代表没有收获...

前端好说, 后端特别是 Java 是真心难弄, 特别是 Spring 的上下文, 基本都需要重启来处理. 找来找去, 最后就找来了 Spring developer tools.

什么是 Spring developer tools ?

Spring developer tools 是 Spring 提供的开发过程中使用的工具, 具体就不介绍了, 有兴趣的可以去看看.
Pampas 为什么要使用它呢? 因为他有 Automatic restart, 没错, 就是自动重启...

怎么搞?

看看官方文档, 很简单嘛... 加入 depends 就好了:

Maven:

<dependencies>  
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>  

Gradle:

dependencies {  
    compile("org.springframework.boot:spring-boot-devtools")
}

这就搞完啦?

这就搞完啦? 好牛 X 啊... 立马找了个 Demo 项目试了试, 真的可以, 包括改 Request mapping, 启动器啥的随便改啊... 瞬间感觉很屌嘛...

既然这么屌, 还等什么? 赶紧集成进 Pampas ...

刚吹牛逼就踩坑了...

动手集成进 Pampas 过程中发现, galaxydubbo 服务挂了, 这就很尴尬了... 看了看异常堆栈:

Caused by: java.lang.IllegalArgumentException: interface io.terminus.pampas.showcase.user.service.UserService is not visible from class loader  
at com.alibaba.dubbo.common.bytecode.Proxy.getProxy(Proxy.java:98) ~[dubbo-2.5.4.2-SNAPSHOT.jar:2.5.4.2-SNAPSHOT]  
at com.alibaba.dubbo.common.bytecode.Proxy.getProxy(Proxy.java:67) ~[dubbo-2.5.4.2-SNAPSHOT.jar:2.5.4.2-SNAPSHOT]  
at com.alibaba.dubbo.rpc.proxy.javassist.JavassistProxyFactory.getProxy(JavassistProxyFactory.java:35) ~[dubbo-2.5.4.2-SNAPSHOT.jar:2.5.4.2-SNAPSHOT]  

interface ...UserService is not visible from class loader?

这就懵逼了...没见过这种异常啊... dubbo 里面爆出来的...

尝试找了找自己的配置问题, 无果, 还是对照堆栈吧...

com.alibaba.dubbo.common.bytecode.Proxy.getProxy(Proxy.java:98):

public static Proxy getProxy(Class<?>... ics) {  
  return getProxy(ClassHelper.getCallerClassLoader(Proxy.class), ics);
}

没啥价值...

com.alibaba.dubbo.common.bytecode.Proxy.getProxy(Proxy.java:67):

public static Proxy getProxy(ClassLoader cl, Class<?>... ics) {  
  ...
  for(int i=0;i<ics.length;i++){
    String itf = ics[i].getName();
    if( !ics[i].isInterface() )
      throw new RuntimeException(itf + " is not a interface.");

    Class<?> tmp = null;
    try {
      tmp = Class.forName(itf, false, cl);
    }
    catch(ClassNotFoundException e){}

    // 是他, 是他, 就是他
    if( tmp != ics[i] )
      throw new IllegalArgumentException(ics[i] + " is not visible from class loader");

      sb.append(itf).append(';');
    }
    ...
}

if (tmp != ics[i]) throw ... ???

仔细看了看, 原来是通过 Proxy 的类加载器, 重新加载一遍需要代理的 Class, 确保是同一个 ClassLoader 加载的同一个 Class.

那为啥会出这问题呢?

咋回事呢?

到此, 想起了之前看 Spring developer tools 中的介绍, 恍然大悟... 介绍如下:

The restart technology provided by Spring Boot works by using two classloaders. Classes that don’t change (for example, those from third-party jars) are loaded into a base classloader. Classes that you’re actively developing are loaded into a restart classloader. When the application is restarted, the restart classloader is thrown away and a new one is created. This approach means that application restarts are typically much faster than “cold starts” since the base classloader is already available and populated.

说的很明白, 就是说用了两个 ClassLoader, 一个用于加载不会变化的, 另外一个加载需要变化的, 当需要重启时, 重新创建一个新的 RestartClassLoeader.

验证一下, debug 试试...

如上图右下方, watch 的变量可以看到, tmpics[i]ClassLoader 是不同的, ics[i] 的是 RestartClassLoader. 这是因为默认编译目录下的 Class 会加入需重启的 ClassLoader.

org.springframework.boot.devtools.restart.ChangeableUrls:

private ChangeableUrls(URL... urls) {  
  DevToolsSettings settings = DevToolsSettings.get();
  List<URL> reloadableUrls = new ArrayList<URL>(urls.length);
  for (URL url : urls) {
    if ((settings.isRestartInclude(url) || isFolderUrl(url.toString()))
        && !settings.isRestartExclude(url)) {
      reloadableUrls.add(url);
    }
  }
  if (logger.isDebugEnabled()) {
    logger.debug("Matching URLs for reloading : " + reloadableUrls);
  }
  this.urls = Collections.unmodifiableList(reloadableUrls);
}

其中 isFolderUrl 就是这个判断, 其实就一行:

private boolean isFolderUrl(String urlString) {  
  return urlString.startsWith("file:") && urlString.endsWith("/");
}

原因找到了

因为自己写的业务类会默认加入 RestartClassLoader 中, 而 Jar 包中的并不会, 导致 ClassLoader 差异.

堂堂 Spring 怎么可能没考虑到这个问题? 翻翻文档找找看...

The spring-devtools.properties file can contain restart.exclude. and restart.include. prefixed properties. The include elements are items that should be pulled up into the “restart” classloader, and the exclude elements are items that should be pushed down into the “base” classloader. The value of the property is a regex pattern that will be applied to the classpath.

For example:

restart.exclude.companycommonlibs=/mycorp-common-[\\w-]+\.jar  
restart.include.projectcommon=/mycorp-myproj-[\\w-]+\.jar  

写一下 include 就可以了嘛, 很简单嘛... 赶紧写一个:

restart.include.dubbo=/dubbo-.*\\.jar  

DubboJar 引入, 这下应该好了吧...

然而...并没有好...

不应该啊... Spring 大法怎么会出问题呢... 没辙了, debug 跟源码吧...

过程中比较痛苦, 就不一一详表了...总之, 目标就是看 DubboProxy 这个类, 是怎么加载进来的, 为什么 include 没有生效?

最后发现, Dubbo 相关的类, 是由 rpc-dubbostarter 带起来的...

好家伙... 原来是你...

目标明确了, 我们把 rpc-dubbo 也加入 include...

restart.include.dubbo=/dubbo-.*\\.jar  
restart.include.roc.dubbo=/rpc-dubbo-.*\\.jar  

改完 Debug 看看:

哎呦, 不错哦... 这下该没问题了吧?

然而...并没有好...

ClassLoader 确实对了, 然而因为不同 ClassLoader 会引发一些其他的问题, 目前已知的就是 APM 的监控包挂了...

***************************
APPLICATION FAILED TO START  
***************************
Description:  
Parameter 1 of constructor in io.terminus.boot.actuator.RpcActuatorAutoConfiguration$DubboActuatorConfig required a bean of type 'io.terminus.boot.rpc.dubbo.properties.DubboProperties' that could not be found.  
Action:  
Consider defining a bean of type 'io.terminus.boot.rpc.dubbo.properties.DubboProperties' in your configuration.  

好在, Pampas hot reload 是开发阶段使用, 对监控没要求, 索性直接把引用去掉了, 对于生产等环境也不会造成影响, 也免去了把 actuator 也加入 include 列表的情况...

然而...并没有好...

去掉监控包的以来之后, 确实可以启动, 并且也可以出发 restart , 然而新的问题接踵而至...

restart 时, Netty 的端口( 也就是 dubbo 监听的端口, dubbo 默认使用 Netty 做通讯 )被占用, 这个问题我尝试 includenetty 的相关包, 然而并没有卵用...

尝试 debug 追踪到 netty 确实也是 RestartClassLoader 启动的, 但是确实在 ClassLoader 被销毁之后没有释放端口, 尝试了很多办法, 最终没有解决...

咋整?

这能咋整?

一是尝试继续查找 Netty 端口没有被释放的原因, 比如绑定了 static 的资源, 导致 restart 的时候没有生效( 重载 ClassLoader 不会改变 static 值, 这是因为 JVM 内存区的原因 ).

二是弃用 Dubbo 这个方式, 使用 Spring cloud 等...这个有点绕道的意思, 不过现阶段确实没有办法不用 Dubbo... 另外就是不排除其他类似情况的出现, 这就需要了解框架的深层机制...

写在最后

最后... 提一句后端的 Hot reload , 发展几十年至今, 仍然没有很好的方案能够解决, 主要是因为后端一般上下文庞大, 依赖中间件也比较多, Spring developer tools 对于开发阶段来说, 体验还是不错的, 速度可以接受. 如果没有遇见上述问题, 完全可以提升开发期的效率的.

有兴趣的小伙伴, 可以试用一下.

耿荣

Read more posts by this author.

中国浙江省杭州市

Subscribe to The Terminus Blog

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!