啟用 Spring-Cloud-OpenFeign 配置可重新整理,專案無法啟動,我 TM 人傻了(下)

乾貨滿滿張雜湊發表於2021-10-02

image

本篇文章涉及底層設計以及原理,以及問題定位,比較深入,篇幅較長,所以拆分成上下兩篇:

  • :問題簡單描述以及 Spring Cloud RefreshScope 的原理
  • :當前 spring-cloud-openfeign + spring-cloud-sleuth 帶來的 bug 以及如何修復

Spring Cloud 中的配置動態重新整理

其實在測試的程式中,我們已經實現了一個簡單的 Bean 重新整理的設計。Spring Cloud 的自動重新整理中,包含兩種元素的重新整理,分別是:

  • 配置重新整理,即 Environment.getProperties@ConfigurationProperties 相關 Bean 的重新整理
  • 新增了 @RefreshScope 註解的 Bean 的重新整理

@RefreshScope 註解其實和我們上面自定義 Scope 使用的註解配置類似,即指定名稱為 refresh,同時使用 CGLIB 代理:

RefreshScope

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Scope("refresh")
@Documented
public @interface RefreshScope {
	ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;

}

同時需要自定義 Scope 進行註冊,這個自定義的 Scope 即 org.springframework.cloud.context.scope.refresh.RefreshScope,他繼承了 GenericScope,我們先來看這個父類,我們專注我們前面測試的那三個 Scope 介面方法,首先是 get:

private BeanLifecycleWrapperCache cache = new BeanLifecycleWrapperCache(new StandardScopeCache());

@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
    //放入快取
	BeanLifecycleWrapper value = this.cache.put(name, new BeanLifecycleWrapper(name, objectFactory));
	this.locks.putIfAbsent(name, new ReentrantReadWriteLock());
	try {
	    //這裡在第一次呼叫會建立 Bean 例項,所以需要上鎖,保證只建立一次
		return value.getBean();
	}
	catch (RuntimeException e) {
		this.errors.put(name, e);
		throw e;
	}
}

然後是註冊 Destroy 的回撥,其實就放在對應的 Bean 中,在移除的時候,會呼叫這個回撥:

@Override
public void registerDestructionCallback(String name, Runnable callback) {
	BeanLifecycleWrapper value = this.cache.get(name);
	if (value == null) {
		return;
	}
	value.setDestroyCallback(callback);
}

最後是移除 Bean,就更簡單了,從快取中移除這個 Bean:

@Override
public Object remove(String name) {
	BeanLifecycleWrapper value = this.cache.remove(name);
	if (value == null) {
		return null;
	}
	return value.getBean();
}

這樣,如果快取中的 bean 被移除,下次呼叫 get 的時候,就會重新生成 Bean。並且,由於 RefreshScope 註解中預設的 ScopedProxyMode 為 CGLIB 代理模式,所以每次通過 BeanFactory 獲取 Bean 以及自動裝載的 Bean 呼叫的時候,都會呼叫這裡 Scope 的 get 方法。

Spring Cloud 將動態重新整理介面通過 Spring Boot Actuator 進行暴露,對應路徑是 /actuator/refresh,對應原始碼是:

RefreshEndpoint

@Endpoint(id = "refresh")
public class RefreshEndpoint {

	private ContextRefresher contextRefresher;

	public RefreshEndpoint(ContextRefresher contextRefresher) {
		this.contextRefresher = contextRefresher;
	}

	@WriteOperation
	public Collection<String> refresh() {
		Set<String> keys = this.contextRefresher.refresh();
		return keys;
	}

}

可以看出其核心是 ContextRefresher,他的核心邏輯也非常簡單:

ContextRefresher

public synchronized Set<String> refresh() {
	Set<String> keys = refreshEnvironment();
	//重新整理 RefreshScope
	this.scope.refreshAll();
	return keys;
}

public synchronized Set<String> refreshEnvironment() {
    //提取 SYSTEM、JNDI、SERVLET 之外所有引數變數
	Map<String, Object> before = extract(this.context.getEnvironment().getPropertySources());
	//從配置源更新 Environment 中的所有屬性
	updateEnvironment();
	//與重新整理前作對比,提取出所有變了的屬性
	Set<String> keys = changes(before, extract(this.context.getEnvironment().getPropertySources())).keySet();
	//將該變了的屬性,放入 EnvironmentChangeEvent 併發布
	this.context.publishEvent(new EnvironmentChangeEvent(this.context, keys));
	//返回所有改變的屬性
	return keys;
}

呼叫 RefreshScope 的 RefreshAll,其實就是呼叫我們上面說的 GenericScope 的 destroy,之後釋出 RefreshScopeRefreshedEvent:

public void refreshAll() {
	super.destroy();
	this.context.publishEvent(new RefreshScopeRefreshedEvent());
}

GenericScope 的 destroy 其實就是將快取清空,這樣所有標註 @RefreshScope 註解的 Bean 都會被重建。

問題定位

通過上篇的原始碼分析,我們知道,如果想實現 Feign.Options 的動態重新整理,目前我們不能把它放入 NamedContextFactory 生成的 ApplicationContext 中,而是需要將它放入專案的根 ApplicationContext 中,這樣 Spring Cloud 暴露的 refresh actuator 介面,才能正確重新整理。spring-cloud-openfeign 中,也是這麼實現的。

如果配置了

feign.client.refresh-enabled: true

那麼在初始化每個 FeignClient 的時候,就會將 Feign.Options 這個 Bean 註冊到根 ApplicationContext,對應原始碼:

FeignClientsRegistrar

private void registerOptionsBeanDefinition(BeanDefinitionRegistry registry, String contextId) {
	if (isClientRefreshEnabled()) {
	    //使用 "feign.Request.Options-FeignClient 的 contextId" 作為 Bean 名稱
		String beanName = Request.Options.class.getCanonicalName() + "-" + contextId;
		BeanDefinitionBuilder definitionBuilder = BeanDefinitionBuilder
				.genericBeanDefinition(OptionsFactoryBean.class);
		//設定為 RefreshScope
		definitionBuilder.setScope("refresh");
		definitionBuilder.addPropertyValue("contextId", contextId);
		BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(definitionBuilder.getBeanDefinition(),
				beanName);
		//註冊為 CGLIB 代理的 Bean
		definitionHolder = ScopedProxyUtils.createScopedProxy(definitionHolder, registry, true);
		//註冊 Bean
		BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, registry);
	}
}

private boolean isClientRefreshEnabled() {
	return environment.getProperty("feign.client.refresh-enabled", Boolean.class, false);
}

這樣,在呼叫 /actuator/refresh 介面的時候,這些 Feign.Options 也會被重新整理。但是註冊到根 ApplicationContext 中的話,對應的 FeignClient 如何獲取這個 Bean 使用呢?即在 Feign 的 NamedContextFactory (即 FeignContext )中生成的 ApplicationContext 中,如何找到這個 Bean 呢?

這個我們不用擔心,因為所有的 NamedContextFactory 生成的 ApplicationContext 的 parent,都設定為了根 ApplicationContext,參考原始碼:

public abstract class NamedContextFactory<C extends NamedContextFactory.Specification>
		implements DisposableBean, ApplicationContextAware {
	private ApplicationContext parent;
	
	@Override
	public void setApplicationContext(ApplicationContext parent) throws BeansException {
		this.parent = parent;
	}
	
	protected AnnotationConfigApplicationContext createContext(String name) {
		//省略其他程式碼
		if (this.parent != null) {
			// Uses Environment from parent as well as beans
			context.setParent(this.parent);
		}
		//省略其他程式碼
	}
}

這樣設定後,FeignClient 在自己的 ApplicationContext 中如果找不到的話,就會去 parent 的 ApplicationContext 也就是根 ApplicationContext 去找。

這樣看來,設計是沒問題的,但是我們的專案啟動不了,應該是啟用其他依賴導致的。

我們在獲取 Feign.Options Bean 的地方打斷點除錯,發現並不是直接從 FeignContext 中獲取 Bean,而是從 spring-cloud-sleuth 的 TraceFeignContext 中獲取的。

spring-cloud-sleuth 為了保持鏈路,在很多地方增加了埋點,對於 OpenFeign 也不例外。在 FeignContextBeanPostProcessor,將 FeignContext 包裝了一層變成了 TraceFeignContext

public class FeignContextBeanPostProcessor implements BeanPostProcessor {

	private final BeanFactory beanFactory;

	public FeignContextBeanPostProcessor(BeanFactory beanFactory) {
		this.beanFactory = beanFactory;
	}

	@Override
	public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
		return bean;
	}

	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
		if (bean instanceof FeignContext && !(bean instanceof TraceFeignContext)) {
			return new TraceFeignContext(traceFeignObjectWrapper(), (FeignContext) bean);
		}
		return bean;
	}

	private TraceFeignObjectWrapper traceFeignObjectWrapper() {
		return new TraceFeignObjectWrapper(this.beanFactory);
	}

}

這樣,FeignClient 會從這個 TraceFeignContext 中讀取 Bean,而不是 FeignContext。但是通過原始碼我們發現,TraceFeignContext 並沒有設定 parent 為根 ApplicationContext,所以找不到註冊到根 ApplicationContext 中的 Feign.Options 這些 Bean。

解決問題

針對這個 Bug,我向 spring-cloud-sleuth 和 spring-cloud-commons 分別提了修改:

大家如果在專案中使用了 spring-cloud-sleuth,對於 spring-cloud-openfeign 想開啟自動重新整理的話,可以考慮使用同名同路徑的類替換程式碼先解決這個問題。等待我提交的程式碼釋出新版本了。

參考程式碼:

public class FeignContextBeanPostProcessor implements BeanPostProcessor {
    private static final Field PARENT;
    private static final Log logger = LogFactory.getLog(FeignContextBeanPostProcessor.class);

    static {
        try {
            PARENT = NamedContextFactory.class.getDeclaredField("parent");
            PARENT.setAccessible(true);
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    private final BeanFactory beanFactory;

    public FeignContextBeanPostProcessor(BeanFactory beanFactory) {
        this.beanFactory = beanFactory;
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof FeignContext && !(bean instanceof TraceFeignContext)) {
            FeignContext feignContext = (FeignContext) bean;
            TraceFeignContext traceFeignContext = new TraceFeignContext(traceFeignObjectWrapper(), feignContext);
            try {
                traceFeignContext.setApplicationContext((ApplicationContext) PARENT.get(bean));
            } catch (IllegalAccessException e) {
                logger.warn("Cannot find parent in FeignContext: " + beanName);
            }
            return traceFeignContext;
        }
        return bean;
    }

    private TraceFeignObjectWrapper traceFeignObjectWrapper() {
        return new TraceFeignObjectWrapper(this.beanFactory);
    }
}

微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章