使用devtools导致的类型转换异常及Spring Devtools 源码初步解析

1遇到的问题

SpringBoot项目中的热部署引发的血的教训,报错代码如下(第6行强制转换,明确可以肯定此处Object肯定是 UserInfoDTO):

protected static UserInfoDTO getUserDTO() {
  UserInfoDTO userInfoDTO = null;
  Object obj = getSubjct().getPrincipal();
  Optional.ofNullable(obj).orElseThrow(() -> new SystemException("解析token数据失败"));
  if (null != obj) {
     userInfoDTO = (UserInfoDTO) obj;
  }
 return userInfoDTO;
}

2问题分析

以上配置发现同样的类型(InterBossHeader)竟然出现了类型转换异常!WQNMMP—。— 分析出ClassLoader不同导致的类型转换异常,Spring的dev-tools为了实现重新装载class自己实现了一个类加载器,来加载项目中会改变的类,方便重启时将新改动的内容更新进来,其实其中官方文档中是有做说明的:

By default, any open project in your IDE will be loaded using the

“restart” classloader, and any regular .jar file will be loaded using

the “base” classloader. If you work on a multi-module project, and not

each module is imported into your IDE, you may need to customize

things. To do this you can create a

META-INF/spring-devtools.properties file.

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.

3解决方式

第一种解决方案:

在resources目录下面创建META_INF文件夹,然后创建spring-devtools.properties文件,文件加上类似下面的配置:

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

第二种解决方案:不使用spring-boot-devtools(你可以当我没说

)

第三种解决方案:

不使用java 的强制转换,使用了

com.alibaba.fastjson.JSON

包进行装换,完成了对象的转换。我对于文章开头所述异常做了以下处理(感觉不用devtools就完全没必要这么搞)

protected static UserInfoDTO getUserDTO() {
   UserInfoDTO userInfoDTO = null;
   Object obj = getSubjct().getPrincipal();
   Optional.ofNullable(obj).orElseThrow(() -> new SystemException("解析token数据失败"));
   if (null != obj) {
       userInfoDTO = new UserInfoDTO();
       if(obj instanceof UserInfoDTO) {
            userInfoDTO = (UserInfoDTO) obj;
       } else {
            userInfoDTO = JSON.parseObject(JSON.toJSON(obj).toString(), UserInfoDTO.class);
       }
     return userInfoDTO;
   }
 return userInfoDTO;
}

至此,问题解决了,这个问题困扰了笔者很久,还是同事提示考虑到是热启动插件的问题,我个人来讲的话想的有点偏,总觉得是序列换的问题,说来也是惭愧。

最近很忙,就好比一个多线程一样,从来都没有停歇过,所以更文也比较少,大家多担待

4源码分析

之所以想看下源码,主要是想解决三个疑问

1 如何初始化

2 如何实时监听

3 如何远程重启

01

构造 Restarter

在Restarter是在spring容器启动过程中通过RestartApplicationListener接受ApplicationStartingEvent广播然后进行一系列初始化操作并实时监听 首先RestartApplicationListener接受ApplicationStartingEvent事件广播并判断spring.devtools.restart.enabled是否开启如果开启就进行初始化如下操作

private void onApplicationStartingEvent(ApplicationStartingEvent event) {
        String enabled = System.getProperty("spring.devtools.restart.enabled");
        if (enabled != null && !Boolean.parseBoolean(enabled)) {
            Restarter.disable();
        } else {
            String[] args = event.getArgs();
            DefaultRestartInitializer initializer = new DefaultRestartInitializer();
            boolean restartOnInitialize = !AgentReloader.isActive();
            Restarter.initialize(args, false, initializer, restartOnInitialize);
        }

    }

然后调用如下初始化方法

    protected void initialize(boolean restartOnInitialize) {
        this.preInitializeLeakyClasses();
        if (this.initialUrls != null) {
            this.urls.addAll(Arrays.asList(this.initialUrls));
            if (restartOnInitialize) {
                this.logger.debug("Immediately restarting application");
                this.immediateRestart();
            }
        }

    }

    private void immediateRestart() {
        try {
            this.getLeakSafeThread().callAndWait(() -> {
                this.start(FailureHandler.NONE);
                this.cleanupCaches();
                return null;
            });
        } catch (Exception var2) {
            this.logger.warn("Unable to initialize restarter", var2);
        }

        SilentExitExceptionHandler.exitCurrentThread();
    }

由上面代码可知在immediateRestart方法中会再开一个线程执行this.start(FailureHandler.NONE)方法,这个方法会新起一个线程去初始化上下文,当项目结束后再返回,如下代码

    protected void start(FailureHandler failureHandler) throws Exception {
        Throwable error;
        do {
            error = this.doStart();
            if (error == null) {
                return;
            }
        } while(failureHandler.handle(error) != Outcome.ABORT);

    }

    private Throwable doStart() throws Exception {
        Assert.notNull(this.mainClassName, "Unable to find the main class to restart");
        URL[] urls = (URL[])this.urls.toArray(new URL[0]);
        ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles);
        ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles, this.logger);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Starting application " + this.mainClassName + " with URLs " + Arrays.asList(urls));
        }

        return this.relaunch(classLoader);
    }
 protected Throwable relaunch(ClassLoader classLoader) throws Exception {
        RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, this.args, this.exceptionHandler);
        launcher.start();
        launcher.join();
        return launcher.getError();
    }

由上面代码可知,Restarter会启动RestartLauncher线程然后启动后就将当前线程挂起,等待RestartLauncher线程任务完成。再来看看RestartLauncher线程执行的任务

 public void run() {
        try {
            Class<?> mainClass = this.getContextClassLoader().loadClass(this.mainClassName);
            Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
            mainMethod.invoke((Object)null, this.args);
        } catch (Throwable var3) {
            this.error = var3;
            this.getUncaughtExceptionHandler().uncaughtException(this, var3);
        }

    }

由上面代码可知,RestartLauncher线程会执行启动类的main方法相当于重新创建应用上下文

小结:由上面的流程可知当第一次执行的时候,如果没有关闭spring developer那么就会创建Restarter并将当前线程挂起然后重新起一个新的子线程来创建应用上下文

02

实时监听

主要是通过类FileSystemWatcher进行实时监听 首先启动过程如下 1 在构建Application上下文的时候refreshContext创建bean的时候会扫描LocalDevToolsAutoConfiguration配置的ClassPathFileSystemWatcher进行初始化 并同时初始化对应依赖 如下:

        @Bean
        @ConditionalOnMissingBean
        public ClassPathFileSystemWatcher classPathFileSystemWatcher() {
            URL[] urls = Restarter.getInstance().getInitialUrls();
            ClassPathFileSystemWatcher watcher = new ClassPathFileSystemWatcher(
                    fileSystemWatcherFactory(), classPathRestartStrategy(), urls);
            watcher.setStopWatcherOnRestart(true);
            return watcher;
        }

         @Bean
        public FileSystemWatcherFactory fileSystemWatcherFactory() {
            return this::newFileSystemWatcher;
        }

        private FileSystemWatcher newFileSystemWatcher() {
            Restart restartProperties = this.properties.getRestart();
            FileSystemWatcher watcher = new FileSystemWatcher(true,
                    restartProperties.getPollInterval(),
                    restartProperties.getQuietPeriod());
            String triggerFile = restartProperties.getTriggerFile();
            if (StringUtils.hasLength(triggerFile)) {
                watcher.setTriggerFilter(new TriggerFileFilter(triggerFile));
            }
            List<File> additionalPaths = restartProperties.getAdditionalPaths();
            for (File path : additionalPaths) {
                watcher.addSourceFolder(path.getAbsoluteFile());
            }
            return watcher;
        }

然后会调用ClassPathFileSystemWatcher中InitializingBean接口所对应的afterPropertiesSet方法去启动一个fileSystemWatcher ,在启动fileSystemWatcher的时候会在fileSystemWatcher上注册一个ClassPathFileChangeListener监听用于响应监听的目录发生变动,具体代码如下:

@Override
    public void afterPropertiesSet() throws Exception {
        if (this.restartStrategy != null) {
            FileSystemWatcher watcherToStop = null;
            if (this.stopWatcherOnRestart) {
                watcherToStop = this.fileSystemWatcher;
            }
            this.fileSystemWatcher.addListener(new ClassPathFileChangeListener(
                    this.applicationContext, this.restartStrategy, watcherToStop));
        }
        this.fileSystemWatcher.start();
    }

fileSystemWatcher内部会启动一个Watcher线程用于循环监听目录变动,如果发生变动就会发布一个onChange通知到所有注册的FileChangeListener上去 如下代码

public void start() {
        synchronized (this.monitor) {
            saveInitialSnapshots();
            if (this.watchThread == null) {
                Map<File, FolderSnapshot> localFolders = new HashMap<>();
                localFolders.putAll(this.folders);
                this.watchThread = new Thread(new Watcher(this.remainingScans,
                        new ArrayList<>(this.listeners), this.triggerFilter,
                        this.pollInterval, this.quietPeriod, localFolders));
                this.watchThread.setName("File Watcher");
                this.watchThread.setDaemon(this.daemon);
                this.watchThread.start();
            }
        }
    }

17//------------------------------------Watcher 中的内部执行方法-----------------------------------------------------------------------@Override
        public void run() {
            int remainingScans = this.remainingScans.get();
            while (remainingScans > 0 || remainingScans == -1) {
                try {
                    if (remainingScans > 0) {
                        this.remainingScans.decrementAndGet();
                    }
                    scan();  //监听变动并发布通知
                }
                catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
                remainingScans = this.remainingScans.get();
            }
        }

之前注册的ClassPathFileChangeListener监听器收到通知后会发布一个ClassPathChangedEvent(ApplicationEvent)事件,如果需要重启就中断当前监听线程。如下代码:

@Override
    public void onChange(Set<ChangedFiles> changeSet) {
        boolean restart = isRestartRequired(changeSet);
        publishEvent(new ClassPathChangedEvent(this, changeSet, restart));
    }

    private void publishEvent(ClassPathChangedEvent event) {
        this.eventPublisher.publishEvent(event);
        if (event.isRestartRequired() && this.fileSystemWatcherToStop != null) {
            this.fileSystemWatcherToStop.stop();
        }
    }

上边发布的ClassPathChangedEvent事件会被LocalDevToolsAutoConfiguration中配置的监听器监听到然后如果需要重启就调用Restarter的方法进行重启 如下:

@EventListener
        public void onClassPathChanged(ClassPathChangedEvent event) {
            if (event.isRestartRequired()) {
                Restarter.getInstance().restart(
                        new FileWatchingFailureHandler(fileSystemWatcherFactory()));
            }
        }

03

LiveReload

liveReload用于在修改了源码并重启之后刷新浏览器 可通过

spring.devtools.livereload.enabled = false

关闭

04

远程重启

Spring Boot的开发者工具不仅仅局限于本地开发。你也可以应用在远程应用上。远程应用是可选的。如果你想开启,你需要把devtools的包加到你的打包的jar中:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <excludeDevtools>false</excludeDevtools>
            </configuration>
        </plugin>
    </plugins>
</build>

然后,你还需要设置一个远程访问的秘钥spring.devtools.remote.secret:

spring.devtools.remote.secret=mysecret

开启远程开发功能是有风险的。永远不要在一个真正的生产机器上这么用。

远程应用支持两个方面的功能;一个是服务端,一个是客户端。只要你设置了spring.devtools.remote.secret,服务端就会自动开启。客户端需要你手动来开启。

运行远程应用的客户端

远程应用的客户端被设计成在你的IDE中运行。你需要在拥有和你的远程应用相同的classpath的前提下,运行org.springframework.boot.devtools.RemoteSpringApplication。这个application的参数就是你要连接的远程应用的URL。

例如,如果你用的是Eclipse或者STS,你有一个项目叫my-app,你已经部署在云平台上了,你需要这么做:

  • 从Run菜单选择Run Configurations…
  • 创建一个Java Application的启动配置
  • 使用org.springframework.boot.devtools.RemoteSpringApplication作为启动类
  • 把https://myapp.cfapps.io作为程序的参数(这个URL是你真正的URL)

一个启动的远程应用是这样的:

  .   ____          _                                              __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _          ___               _      \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` |        | _ \___ _ __  ___| |_ ___ \ \ \ \
 \\/  ___)| |_)| | | | | || (_| []::::::[]   / -_) '  \/ _ \  _/ -_) ) ) ) )
  '  |____| .__|_| |_|_| |_\__, |        |_|_\___|_|_|_\___/\__\___|/ / / /
 =========|_|==============|___/===================================/_/_/_/
 :: Spring Boot Remote :: 1.5.3.RELEASE

2015-06-10 18:25:06.632  INFO 14938 --- [           main] o.s.b.devtools.RemoteSpringApplication   : Starting RemoteSpringApplication on pwmbp with PID 14938 (/Users/pwebb/projects/spring-boot/code/spring-boot-devtools/target/classes started by pwebb in /Users/pwebb/projects/spring-boot/code/spring-boot-samples/spring-boot-sample-devtools)
2015-06-10 18:25:06.671  INFO 14938 --- [           main] s.c.a.AnnotationConfigApplicationContext : Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@2a17b7b6: startup date [Wed Jun 10 18:25:06 PDT 2015]; root of context hierarchy
2015-06-10 18:25:07.043  WARN 14938 --- [           main] o.s.b.d.r.c.RemoteClientConfiguration    : The connection to http://localhost:8080 is insecure. You should use a URL starting with 'https://'.
2015-06-10 18:25:07.074  INFO 14938 --- [           main] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2015-06-10 18:25:07.130  INFO 14938 --- [           main] o.s.b.devtools.RemoteSpringApplication   : Started RemoteSpringApplication in 0.74 seconds (JVM running for 1.105)

因为classpath是一样的,所以可以直接读取真实的配置属性。这就是spring.devtools.remote.secret发挥作用的时候了,Spring Boot会用这个来认证。

建议使用https://来连接,这样密码会被加密,不会被拦截。

如果你有一个代理服务器,你需要设置spring.devtools.remote.proxy.host和spring.devtools.remote.proxy.port这两个属性。

远程更新:

客户端会监控你的classpath,和本地重启的监控一样。任何资源更新都会被推送到远程服务器上,远程应用再判断是否触发了重启。如果你在一个云服务器上做迭代,这样会很有用。一般来说,字节更新远程应用,会比你本地打包再发布要快狠多。

资源监控的前提是你启动了本地客户端,如果你在启动之前修改了文件,这个变化是不会推送到远程应用的。

远程DEBUG通道:

在定位和解决问题时,Java远程调试是很有用的。不幸的是,如果你的应用部署在异地,远程debug往往不是很容易实现。而且,如果你使用了类似Docker的容器,也会给远程debug增加难度。

为了解决这么多困难,Spring Boot支持在HTTP层面的debug通道。远程应用汇提供8000端口来作为debug端口。一旦连接建立,debug信号就会通过HTTP传输给远程服务器。你可以设置spring.devtools.remote.debug.local-port来改变默认端口。 你需要首先确保你的远程应用启动时已经开启了debug模式。一般来说,可以设置JAVA_OPTS。例如,如果你使用的是Cloud Foundry你可以在manifest.yml加入:

    env:
        JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n"

注意,没有必要给-Xrunjdwp加上address=NNNN的配置。如果不配置,Java会随机选择一个空闲的端口。 远程debug是很慢的,所以你最好设置好debug的超时时间(一般来说60000是足够了)。 如果你使用IntelliJ IDEA来调试远程应用,你一定要把所有断点设置成悬挂线程,而不是悬挂JVM。默认情况,IDEA是悬挂JVM的。这个会造成很大的影响,因为你的session会被冻结。参考IDEA-165769

5总结

前言万语,写代码之前最好认真思考,否则一顿操作猛如虎,到头来代码还是要一遍一遍回滚掉,正如上个礼拜的我,天天写,写完发现到了晚上又一行行恢复,感觉职业生涯达到了瓶颈

还好及时悬崖勒马 跑回来了!

原文发布于微信公众号 - 程序猿杂货铺(zhoudl_l)

原文发表时间:2018-11-21

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券