DOIFOR技术Springboot devtools自动重启引发的问题
DOIFOR技术Springboot devtools自动重启引发的问题

Springboot devtools自动重启引发的问题

技术

开门见山

我们系统中使用到了Springboot的devtools,然后再启动类中设置了环境变量:

public static void main(String[] args) {
        SpringApplication.run(Application.class, ArrayUtils.add(args, "--SYS_ENV=t3"));
    }

然后,系统中有一个service方法去获取这个环境变量,并进行相关判断:

@Component
public class EnvService {

    /** 系统环境标识 */
    private static final String T3_ENV = "t3";

    /** 系统环境标识 */
    private static final String SYS_ENV = "SYS_ENV";

    @Autowired
    private Environment environment;

    /**
     * 判断是否是T3环境
     *
     * @return 是否
     */
    public boolean isT3() {
        return StringUtils.equals(T3_ENV, environment.getProperty(SYS_ENV));
    }
}

上面代码看起来很正常,在测试环境和稳定环境中均表现正常。但是诡异的事情发生了,在研发环境中返回总是false经排查,研发环境中environment.getProperty(SYS_ENV)返回为t3,t3

寻龙点穴

全文搜索了一下"t3c"这个字符串,发现只有main方法中有。于是乎,我开始怀疑run方法中设置环境变量有问题,然后我就一步一步的进入到使用args参数的地方:

public ConfigurableApplicationContext run(String... args) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        ConfigurableApplicationContext context = null;
        Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
        configureHeadlessProperty();
        // 这儿有使用
        SpringApplicationRunListeners listeners = getRunListeners(args);
        listeners.starting();
        try {
            // 这儿也有使用
            ApplicationArguments applicationArguments = new DefaultApplicationArguments(
                    args);
            ConfigurableEnvironment environment = prepareEnvironment(listeners,
                    applicationArguments);
            configureIgnoreBeanInfo(environment);
            Banner printedBanner = printBanner(environment);
            context = createApplicationContext();
            exceptionReporters = getSpringFactoriesInstances(
                    SpringBootExceptionReporter.class,
                    new Class[] { ConfigurableApplicationContext.class }, context);
            prepareContext(context, environment, listeners, applicationArguments,
                    printedBanner);
            refreshContext(context);
            afterRefresh(context, applicationArguments);
            stopWatch.stop();
            if (this.logStartupInfo) {
                new StartupInfoLogger(this.mainApplicationClass)
                        .logStarted(getApplicationLog(), stopWatch);
            }
            listeners.started(context);
            callRunners(context, applicationArguments);
        }
        catch (Throwable ex) {
            handleRunFailure(context, listeners, exceptionReporters, ex);
            throw new IllegalStateException(ex);
        }
        listeners.running(context);
        return context;
    }

好吧,代码太多了。干啃源码有点啃不动了,索性来个断点一步一步跟着走吧。然后我就在SpringApplicationRunListeners listeners = getRunListeners(args);处放了一个断点:

断点

重启一下。这个时候懒惰起到了作用,第一次断点时候我直接放了。

第一次断点

本想看看程序问题是否有改变,结果有一次进断点了。呵呵哒,无心插柳柳成荫啊。

第二次

看到了点有意思的东西,有个叫做RestartLauncher的东西,在devtools包里面。干脆果断点验证一下是不是devtools在捣鬼,找到pom中里面的依赖,直接注释掉。再重启,正常了。

那么线上环境为啥为没有问题呢?

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    ...
    <configuration>
        ...
        <excludeDevtools>true</excludeDevtools>
    </configuration>
    ...
</plugin>

加了<excludeDevtools>true</excludeDevtools>这个配置,在打包的时候直接排除了devtools。这波操作非常优秀!!!

考虑到我们在VM options中设置的参数没有被设置两次,因此如果仅仅是要解决目前遇到的问题,有两种方法:

  • 直接去除项目中的devtools依赖以及相关配置,但是这种方案显然太过于粗暴了。
  • 不在main方法中设置参数,在启动参数中设置-DSYS_ENV=t3,可以相对优雅的解决问题。

到此问题算是有了解决方案,但是事情就这么结束了吗?我还是有些不甘心,想着万一找着一个springbug也有了一些谈资,那就继续吧!

没有标题

本章确实想不到名字了,都怪平时读书太少啊!

就两个问题:

  • 为什么为执行执行两次?
  • 为什么会重复设置参数?

为什么会执行两次?

关键代码:

/** org.springframework.boot.SpringApplication#run(java.lang.String...) */
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting();

此处会启动所有监视器,在devtools中有个叫做RestartApplicationListener的监视器:

/** org.springframework.boot.devtools.restart.RestartApplicationListener */
private void onApplicationStartingEvent(ApplicationStartingEvent event) {
    // It's too early to use the Spring environment but we should still allow
    // users to disable restart using a System property.
    String enabled = System.getProperty(ENABLED_PROPERTY);
    if (enabled == null || Boolean.parseBoolean(enabled)) {
        String[] args = event.getArgs();
        DefaultRestartInitializer initializer = new DefaultRestartInitializer();
        boolean restartOnInitialize = !AgentReloader.isActive();
        Restarter.initialize(args, false, initializer, restartOnInitialize);
    }
    else {
        Restarter.disable();
    }
}

此处使用到了spring.devtools.restart.enabled配置,可以使用该配置屏蔽该监视器的使用。但是如果将该配置文件中写在配置文件中的话是无效的,只能写在启动启动脚本中。

在这个监视器中初始化了一个叫做Restarter的实例,该实例时单例的。关键代码:Restarter.initialize(args, false, initializer, restartOnInitialize);, 继续跟踪Restarter中的源码,查看该方法实现:

/** org.springframework.boot.devtools.restart.Restarter */
public static void initialize(String[] args, boolean forceReferenceCleanup,
            RestartInitializer initializer, boolean restartOnInitialize) {
    ...
    if (localInstance != null) {
        // 关键代码
        localInstance.initialize(restartOnInitialize);
    }
}

protected void initialize(boolean restartOnInitialize) {
    preInitializeLeakyClasses();
    if (this.initialUrls != null) {
        this.urls.addAll(Arrays.asList(this.initialUrls));
        if (restartOnInitialize) {
            this.logger.debug("Immediately restarting application");
            // 关键代码
            immediateRestart();
        }
    }
}
private void immediateRestart() {
    try {
        getLeakSafeThread().callAndWait(() -> {
            // 关键代码
            start(FailureHandler.NONE);
            cleanupCaches();
            return null;
        });
    }
    ...
}
protected void start(FailureHandler failureHandler) throws Exception {
    do {
        // 关键代码
        Throwable error = doStart();
        ...
    }
    while (true);
}
private Throwable doStart() throws Exception {
    ...
    // 关键代码
    return relaunch(classLoader);
}
protected Throwable relaunch(ClassLoader classLoader) throws Exception {
    // 最关键代码来了
    RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName,
                this.args, this.exceptionHandler);
    ...
}

此时将会开启一个新的线程:RestartLauncher,线程中将会再次执行main方法。

/** org.springframework.boot.devtools.restart.RestartLauncher#run */
@Override
public void run() {
    try {
        Class<?> mainClass = getContextClassLoader().loadClass(this.mainClassName);
        Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[] { this.args });
    }
    catch (Throwable ex) {
        this.error = ex;
        getUncaughtExceptionHandler().uncaughtException(this, ex);
    }
}

到此,已经明了为什么为执行两次run方法了。

为什么会重复设置参数?

这两行代码:

Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.invoke(null, new Object[] { this.args });

在重启的时候会将上次启动的参数作为本次执行的参数,然后在main中会再加一次参数:

SpringApplication.run(ZnbqApplication.class, ArrayUtils.add(args, "--SYS_ENV=t3"));

相当于:

SpringApplication.run(ZnbqApplication.class, ArrayUtils.add("--SYS_ENV=t3", "--SYS_ENV=t3"));

嗯,这个问题算是真相大白了,重启相当于递归调用了一次main方法,参数也就多设置了一次。

尘埃落地

综上所述,解决这个问题目前有四种方法:

  1. 直接去除devtools
  2. main中的--SYS_ENV=t3改为-DSYS_ENV=t3设置到启动参数中。
  3. 在启动参数中增加-Dspring.devtools.restart.enabled=false配置,屏蔽RestartApplicationListener监视器。
  4. 还有一种比较骚的操作,将main中的--SYS_ENV=t3改为--t3,然后把EnvService中的判断改为:environment.containsProperty("t3")

这个问题虽然影响不大,也不常见,但遇到了还是挺麻烦的。折磨了我们团队的几个兄弟伙很久,真实汗颜啊。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注