Springboot devtools的一个小坑

开门见山

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

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

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

@Component
public class EnvService {

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

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

    @Autowired
    private Environment environment;

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

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

寻龙点穴

全文搜索了一下"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=t3c,可以相对优雅的解决问题。

到此问题算是有了解决方案,但是事情就这么结束了吗?我还是有些不甘心,想着万一找着一个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=t3c"));

相当于:

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

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

尘埃落地

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

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

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

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×