启用 Spring-Cloud-OpenFeign 配置可刷新,项目无法启动(下)

简介: 启用 Spring-Cloud-OpenFeign 配置可刷新,项目无法启动(下)

image.png


本篇文章涉及底层设计以及原理,以及问题定位,比较深入,篇幅较长,所以拆分成上下两篇:

  • :问题简单描述以及 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);
    }
}
相关文章
|
15天前
|
Java Spring
【Spring】方法注解@Bean,配置类扫描路径
@Bean方法注解,如何在同一个类下面定义多个Bean对象,配置扫描路径
144 73
|
15天前
|
Java Spring
【Spring配置相关】启动类为Current File,如何更改
问题场景:当我们切换类的界面的时候,重新启动的按钮是灰色的,不能使用,并且只有一个Current File 项目,下面介绍两种方法来解决这个问题。
|
15天前
|
存储 JSON 前端开发
【Spring项目】表白墙,留言板项目的实现
本文主要介绍了表白墙项目的实现,包含前端和后端代码,以及测试
|
15天前
|
JSON 前端开发 Java
|
15天前
|
Java Spring
【Spring配置】idea编码格式导致注解汉字无法保存
问题一:对于同一个项目,我们在使用idea的过程中,使用汉字注解完后,再打开该项目,汉字变成乱码问题二:本来a项目中,汉字注解调试好了,没有乱码了,但是创建出来的新的项目,写的注解又成乱码了。
|
15天前
|
Java Spring
【Spring配置】创建yml文件和properties或yml文件没有绿叶
本文主要针对,一个项目中怎么创建yml和properties两种不同文件,进行配置,和启动类没有绿叶标识进行解决。
|
15天前
|
缓存 前端开发 Java
【Spring】——SpringBoot项目创建
SpringBoot项目创建,SpringBootApplication启动类,target文件,web服务器,tomcat,访问服务器
|
21天前
|
XML Java 数据格式
Spring容器Bean之XML配置方式
通过对以上内容的掌握,开发人员可以灵活地使用Spring的XML配置方式来管理应用程序的Bean,提高代码的模块化和可维护性。
57 6
|
2天前
|
Java 测试技术 应用服务中间件
Spring Boot 如何测试打包部署
本文介绍了 Spring Boot 项目的开发、调试、打包及投产上线的全流程。主要内容包括: 1. **单元测试**:通过添加 `spring-boot-starter-test` 包,使用 `@RunWith(SpringRunner.class)` 和 `@SpringBootTest` 注解进行测试类开发。 2. **集成测试**:支持热部署,通过添加 `spring-boot-devtools` 实现代码修改后自动重启。 3. **投产上线**:提供两种部署方案,一是打包成 jar 包直接运行,二是打包成 war 包部署到 Tomcat 服务器。
25 10
|
16天前
|
Java 数据库连接 Maven
最新版 | 深入剖析SpringBoot3源码——分析自动装配原理(面试常考)
自动装配是现在面试中常考的一道面试题。本文基于最新的 SpringBoot 3.3.3 版本的源码来分析自动装配的原理,并在文未说明了SpringBoot2和SpringBoot3的自动装配源码中区别,以及面试回答的拿分核心话术。
最新版 | 深入剖析SpringBoot3源码——分析自动装配原理(面试常考)