什么?你说你加一个FeignClient就搞出了一个P0事故?

162 阅读3分钟

背景

周末在家,正忙着在黑神话中(被)暴揍大头娃娃,突然接到一顿夺命连环call,声称我的登录服务挂了,无法正常登录。

我紧忙思考着近期上线的内容,但是怎么也想不到近期有什么登录改动上线。但是出于负责任的态度,我还是分析登录日志,找一些蛛丝马迹。

不找不知道,一找吓一跳,所有的登录UA在日志中都变成了这个:

Apache-HttpClient/4.5.6 (Java/1.8.0_311)

经过缜密的分析,我发现是最近一次上线中,新增了一个FeignClient出现的问题。

原因

相关版本

Spring Boot 2.0.2-RELEASE
Spring Cloud OpenFeign Core 2.0.1.RELEASE

相关操作

新增了一个AuthTokenApi类,注解为@FeignClient(value = "passport-service", path = "/authentication/authToken") 导致整个"passport-service"的client的"configuration"失效,导致feign无法自动透传请求头。

原理

  • org.springframework.cloud.openfeign.FeignClientsRegistrar#registerFeignClients
  public void registerFeignClients(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
    ClassPathScanningCandidateComponentProvider scanner = getScanner();
    scanner.setResourceLoader(this.resourceLoader);
    // 重点,会按照hashCode自动进行打乱排序
    Set<String> basePackages;
​
    Map<String, Object> attrs = metadata
        .getAnnotationAttributes(EnableFeignClients.class.getName());
    AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(
        FeignClient.class);
    final Class<?>[] clients = attrs == null ? null
        : (Class<?>[]) attrs.get("clients");
    if (clients == null || clients.length == 0) {
      scanner.addIncludeFilter(annotationTypeFilter);
      basePackages = getBasePackages(metadata);
    }
​
for (String basePackage : basePackages) {  
	    Set<BeanDefinition> candidateComponents = scanner  
	          .findCandidateComponents(basePackage);  
	    for (BeanDefinition candidateComponent : candidateComponents) {  
	       if (candidateComponent instanceof AnnotatedBeanDefinition) {  
	          // verify annotated class is an interface  
	          AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;  
	          AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();  
	          Assert.isTrue(annotationMetadata.isInterface(),  
	                "@FeignClient can only be specified on an interface");  
	  
	          Map<String, Object> attributes = annotationMetadata  
	                .getAnnotationAttributes(  
	                      FeignClient.class.getCanonicalName());  
	  
	          String name = getClientName(attributes);  
                  // 此时进行了注册FeignClientSpecification
                  //如果有多个FeignClientSpecification,会取最后注册的一个
                  //万恶之源!

	          registerClientConfiguration(registry, name,  
	                attributes.get("configuration"));  
	  
	          registerFeignClient(registry, annotationMetadata, attributes);  
	       }  
	    }  
	}
​

修复动作

修改该注解 @FeignClient(value = "passport-service", path = "/authentication/authToken",configuration = FeignAuthConfig.class)

反思

根本原因在于旧版本Feign中,Client的beanName就是serviceName,导致配置会互相覆盖,容易产生许多意外的错误。

诚然,引入的依赖有着出人意料的坑,很难防范。但是也在于Fegin了解不够透彻,没有对源码有着深入的理解,应该在使用依赖库时,深入的了解其实现原理,减少技术债务,才能更好的避免使用中遇到的问题。

预防措施

总共有3种方案可以解决这个问题

  1. 升级spring cloud feign版本至2.1.x及后续,支持为FeignClient增加contextId字段,可以一劳永逸避免此问题。

    • 优点

      • 一劳永逸,也同时避免各个Client的配置互相污染
    • 缺点

      • 代价较高,需要改造原有项目,原本的坑可能已经成为了业务,排查难度大,升级有风险。
  2. FeignAuthConfig设置为通用配置项,无需每个项目中配置,即可避免类似问题。

    • 优点

      • 无需手动引入,避免手动出现的错误
    • 缺点

      • 部分服务本来无需相关透传信息,因此增加了网络消耗,降低性能;也有可能由于顶替请求头等信息,产生其他意外的错误。
  3. 复盘此问题,告知注解中configuration的重要性,口头约定每个人在自己Feign Client中加入。

    • 优点

      • 无需改动代码
    • 缺点

      • 后续仍有可能出现类似错误,口头约束不保险。

扩展阅读

feign相关

  • 流程

    • 初始化逻辑

      • FeignClientsRegistrar扫描相关注解,根据service name注册FeignClientSpecificationBean
      • FeignContext通过继承NamedContextFactory<FeignClientSpecification> ,获取到所有的相关实例作为configurations
    • 构建实例逻辑

      • FeignClientFactoryBean通过FeignContext.getInstance(name,Feign.Builder.class)为所有FeignClient接口提供实现。
      • FeignClientFactoryBean通过configureFeign方法,对于每个实例进行针对性配置。

spring Bean注册相关

  • DefaultListableBeanFactory#registerBeanDefinition
public void registerBeanDefinition(String beanName, BeanDefinition beanDefinition)
    throws BeanDefinitionStoreException {
​
  // 略
  BeanDefinition oldBeanDefinition;
  oldBeanDefinition = this.beanDefinitionMap.get(beanName);
  if (oldBeanDefinition != null) {
    // 校验与日志逻辑,略
    // 替换旧的BeanDefinition
    this.beanDefinitionMap.put(beanName, beanDefinition);
  }
  else {
    //首次创建,略
  }
​
  if (oldBeanDefinition != null || containsSingleton(beanName)) {
    resetBeanDefinition(beanName);
  }