Feign:从使用url到使用name进行访问的线上过渡实践

1,518 阅读5分钟

相关背景

  1. 业务实例都部署在k8s集群,服务之间调用采用的协议是http,http工具使用的是feign
  2. 服务注册发现采用k8s集群的service,feign的写法都是使用url=${http://service_name}
  3. 为了将服务注册发现的能力掌控在应用程序这边,方便扩展灰度,负载等功能,决定引入nacos为注册中心,使用基于客户端的服务发现和负载均衡(k8s的service能力是基于服务端的)

jar版本依赖

  1. springboot / cloud的基础版本

    1. <properties>
        <springboot.version>2.4.6</springboot.version>
        <spring.cloud.version>2020.0.3</spring.cloud.version>
        <alibaba.version>2021.1</alibaba.version>
      </properties>
      <dependencyManagement>
        <dependencies>
          <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
          </dependency>
          <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring.cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
          </dependency>
          <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${springboot.version}</version>
            <type>pom</type>
            <scope>import</scope>
          </dependency>
        </dependencies>
      </dependencyManagement>
      

遇到的问题

  1. 服务数量多,不允许一次性上线切换(服务数量少也不允许,这是一个危险的行为)

  2. 由于设计的原因,有些服务有依赖顺序,甚至有些服务有循环依赖关系

    1. 假如A服务引用了B服务,C服务,如果此时A服务引入了nacos后,且B,C还未上线,那么A服务对B和C的调用会失败
    2. 假如A服务和B服务相互依赖,那么也会有问题(不管谁先上线,只要有流量就会有问题)

解决思路

让服务发现的能力有一个“过渡”的过程,有变量控制开关决定使用nacos的服务发现还是k8s的服务发现

  1. 举个DEMO,还是A,B,C服务,3者都在k8s集群中,那么A对B的Feign的写法是以下

    1. @FeignClient(name="B",url="http://service-B",contextId="bCtx")
      public interface BFeign{
        @GetMapping
        String helloB();
      }
      
  2. 希望达到的效果

    1. A的feign代码还是保持原先一致,引入nacos的服务注册发现后,在B引入nacos上线之前A依然正常调用B
    2. B的feign代码也是保持这种写法风格,引入nacos的服务注册发现后,B对A的调用正常,A对B的调用也正常

开始动手改造

原理
  1. 在当前版本依赖下,feign构造client的源码是以下这样的(我会把注释标在代码里)

    1. <T> T getTarget() {
          FeignContext context = beanFactory != null ? beanFactory.getBean(FeignContext.class)
              : applicationContext.getBean(FeignContext.class);
          Feign.Builder builder = feign(context);
          // 如果发现FeignClient注解里没有url参数,那么默认当前FeignClient需要客户端发现/负载均衡
          // 但是因为我们要有一个过渡阶段,所以不能直接去掉url用name,不然就会遇到上面描述的问题
          if (!StringUtils.hasText(url)) {
            // 省略N行
            return (T) loadBalance(builder, context, new HardCodedTarget<>(type, name, url));
          }
          if (StringUtils.hasText(url) && !url.startsWith("http")) {
            url = "http://" + url;
          }
          // 走到这里来,就意味着有url属性
          // 那么open feign会认为你是不需要客户端相关能力了,就是一个单纯的http工具了
          String url = this.url + cleanPath();
          Client client = getOptional(context, Client.class);
          if (client != null) {
            // 如果发现引入了spring loadbalancer,也会因为有url,直接把默认的Client
            // 因为我们要使用nacos的服务注册发现,所以我们同时也会引入spring loadbalancer
            if (client instanceof FeignBlockingLoadBalancerClient) {
              client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
            }
            if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
              client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
            }
            builder.client(client);
          }
          Targeter targeter = get(context, Targeter.class);
          return (T) targeter.target(this, builder, context, new HardCodedTarget<>(type, name, url));
        }
      
  1. 从上面的源码得知,因为有url,所以open feign会忽略你隐入的loadbalancer,直接把默认的client取出来使用

    1. 从这个地方可以切入,通过构造我们自己的feign client返回给feign builder

    2. 我们自己的feign client(这里包括下文都称为proxy feign client)需要包括什么功能呢?

      1. 通过读取当前spring环境的配置,来决定使用url进行访问,还是使用name去获取ip:port来访问
  2. 如何把我们构造proxy feign client塞给builder呢?

    1. 这里有一些点要注意,因为open feign的可拓展性,支持开发者自定义client的类型,也支持是否使用loadbalancer能力的client

    2. 基于上述的点,我们不能直接通过Configuration的形式去返回一个client去代替本来的client

      1. 如果我们使用这种方式的话,那么其他上下文会因为条件注解忽略掉本来的client
    3. 如何解决呢?

      1. 动态代理去改变FeignContext获取client的逻辑

        1. FeignContext会根据上下文,条件注解等来综合获取一个client
        2. 利用动态代理,去包装这个获取的行为,构造一个proxy feign client
  3. 开始上刺刀

    1. 注入一个BeanPostProcessor,监听FeignContext的初始化行为

      1. // 注入BeanPostProcessor
        @Configuration
        public class ExampleConfiguration {
            @Bean
            public BeanPostProcessor FeignContextBeanPostProcessor() {
                return new FeignContextBeanPostProcessor();
            }
        }
        
      2. // 
        public class FeignContextBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware {
        ​
            ApplicationContext applicationContext;
        ​
            @Override
            public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
                this.applicationContext = applicationContext;
            }
        ​
            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
                if (!(bean instanceof FeignContext)) {
                    return BeanPostProcessor.super.postProcessAfterInitialization(bean, beanName);
                }
                // 如果是FeignContext,那么使用cglib库创建一个代理返回
                FeignContext feignContext = (FeignContext) bean;
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(FeignContext.class);
                // 代理拦截器
                enhancer.setCallback(new FeignContextMethodInterceptor(feignContext, applicationContext));
                return enhancer.create();
            }
        }
        
      3. public class FeignContextMethodInterceptor implements MethodInterceptor {
            // 定义我们要拦截的方法
            // feignbuilder在获取client时,是通过FeignContext去获取对应的Context,然后从Context获取到client返回
            final String PROXY_METHOD = "getInstance";
        ​
            FeignContext origin;
        ​
            ApplicationContext applicationContext;
        ​
            public FeignContextMethodInterceptor(FeignContext feignContext, ApplicationContext applicationContext) {
                this.origin = feignContext;
                this.applicationContext = applicationContext;
            }
        ​
            @Override
            public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                // 定位获取实例的方法,并且参数是Client类型
                if (PROXY_METHOD.equals(method.getName()) && objects.length == 2 && objects[1].equals(Client.class)) {
                    // 执行原方法
                    Client client = (Client) method.invoke(origin, objects);
                    // 什么情况会为空呢?根据上述FeignClientFactoryBean的源码可以得知
                    // 如果一个FeignClient具有url,但是没有引入负载均衡相关的库,那么就不去设置Client
                    // 有人可能会问,没有client请求不会有问题吗?--答案是不会的,可以去看看源码,feign.Feign.Builder中成员变量client有默认值
                    // 这种情况下,你都没有引入负载均衡器,那实际上我就当你直接使用url了
                    if (client == null) {
                        return null;
                    }
                    // 获取contextId
                    String contextId = (String) objects[0];
                    // 根据contextId查找feign client的工厂bean
                    FeignClientFactoryBean clientFactoryBean = findFeignClientFactoryBeanByContextId(contextId);
                    if (clientFactoryBean == null) {
                        return client;
                    }
                    String url = clientFactoryBean.getUrl();
                    String name = clientFactoryBean.getName();
                    // 如果url和name不同时存在,那按我的理解是 你已经完成了过渡期,你已经做出了你的最佳选择
                    if (StringUtils.isBlank(url) || StringUtils.isBlank(name)) {
                        return client;
                    }
                    // 返回我们的proxy client 
                    return new ProxyClient(client, name, url ,contextId,
                            (StandardEnvironment) applicationContext.getEnvironment());
                } else {
                    return method.invoke(origin, objects);
                }
            }
        ​
            private FeignClientFactoryBean findFeignClientFactoryBeanByContextId(String contextId) {
                String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();
                if (beanDefinitionNames == null || beanDefinitionNames.length == 0) {
                    return null;
                }
                AutowireCapableBeanFactory beanFactory = applicationContext.getAutowireCapableBeanFactory();
                ConfigurableListableBeanFactory configurableListableBeanFactory = (ConfigurableListableBeanFactory) beanFactory;
                for (String beanDefinitionName : beanDefinitionNames) {
                    BeanDefinition beanDefinition = configurableListableBeanFactory.getBeanDefinition(beanDefinitionName);
                    Object feignClientsRegistrarFactoryBean = beanDefinition.getAttribute("feignClientsRegistrarFactoryBean");
                    if (feignClientsRegistrarFactoryBean == null || !(feignClientsRegistrarFactoryBean instanceof FeignClientFactoryBean)) {
                        continue;
                    }
                    FeignClientFactoryBean factoryBean = (FeignClientFactoryBean) feignClientsRegistrarFactoryBean;
                    String cId = factoryBean.getContextId();
                    if (contextId.equals(cId)) {
                        return factoryBean;
                    }
                }
                return null;
            }
        }
        
    2. 简单的ProxyClient

      1. public class ProxyClient implements Client {
            // 定义一个配置的格式
            static final String PROP_PATTERN = "discovery.{0}.type";
            // 如果配置值是client,那么使用客户端发现
            static final String _CLIENT = "client";
            // 如果配置是server,那么使用服务端发现,也就是直接使用url访问
            static final String _SERVER = "server";
        ​
            StandardEnvironment standardEnvironment;
        ​
            Client target;
        ​
            Client noLoadBalancerClient;
        ​
            String contextId;
        ​
            String name;
        ​
            String url;
        ​
            public ProxyClient(Client client, String name, String url, String contextId, StandardEnvironment standardEnvironment) {
                this.name = name;
                this.url = url;
                this.contextId = contextId;
                this.target = client;
                this.standardEnvironment = standardEnvironment;
                // 这个地方跟FeignClientFactoryBean构造feign源码一样,要拿出默认的client
                if (client instanceof FeignBlockingLoadBalancerClient) {
                    noLoadBalancerClient = ((FeignBlockingLoadBalancerClient) client).getDelegate();
                }
                if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
                    noLoadBalancerClient = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
                }
            }
            
            // 从上下文环境中获取变量来决定使用什么方式访问
            // 项目中可以引入配置中心,通过动态配置来达到随时切换的效果!
            public boolean isClientProxyType() {
                String propKey = MessageFormat.format(PROP_PATTERN, contextId);
                String proxyType = standardEnvironment.getProperty(propKey);
                if (_CLIENT.equals(proxyType)) {
                    return true;
                } else if (_SERVER.equals(proxyType)) {
                    return false;
                } else {
                    return true;
                }
            }
        ​
        ​
            @Override
            public Response execute(Request request, Request.Options options) throws IOException {
                boolean clientProxyType = isClientProxyType();
                if (clientProxyType) {
                    Request newRequest = Request.create(request.httpMethod(), reconstructUrl(request.url()),
                            request.headers(), request.body(), request.charset(), request.requestTemplate());
                    return target.execute(newRequest, options);
                } else {
                    return noLoadBalancerClient.execute(request, options);
                }
            }
        ​
            private String reconstructUrl(String url) {
                String host = URI.create(this.url).getHost();
                return StringUtils.replace(url,host,this.name);
            }
        }
        

写在最后

  1. 如果有需要转载,请附上原地址,谢谢啦
  2. 感谢各位大佬的浏览,有什么想法意见,或者文中有什么错误的地方,留言评论!