相关背景
- 业务实例都部署在k8s集群,服务之间调用采用的协议是http,http工具使用的是feign
- 服务注册发现采用k8s集群的service,feign的写法都是使用url=${http://service_name}
- 为了将服务注册发现的能力掌控在应用程序这边,方便扩展灰度,负载等功能,决定引入nacos为注册中心,使用基于客户端的服务发现和负载均衡(k8s的service能力是基于服务端的)
jar版本依赖
-
springboot / cloud的基础版本
-
<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>
-
遇到的问题
-
服务数量多,不允许一次性上线切换(服务数量少也不允许,这是一个危险的行为)
-
由于设计的原因,有些服务有依赖顺序,甚至有些服务有循环依赖关系
- 假如A服务引用了B服务,C服务,如果此时A服务引入了nacos后,且B,C还未上线,那么A服务对B和C的调用会失败
- 假如A服务和B服务相互依赖,那么也会有问题(不管谁先上线,只要有流量就会有问题)
解决思路
让服务发现的能力有一个“过渡”的过程,有变量控制开关决定使用nacos的服务发现还是k8s的服务发现
-
举个DEMO,还是A,B,C服务,3者都在k8s集群中,那么A对B的Feign的写法是以下
-
@FeignClient(name="B",url="http://service-B",contextId="bCtx") public interface BFeign{ @GetMapping String helloB(); }
-
-
希望达到的效果
- A的feign代码还是保持原先一致,引入nacos的服务注册发现后,在B引入nacos上线之前A依然正常调用B
- B的feign代码也是保持这种写法风格,引入nacos的服务注册发现后,B对A的调用正常,A对B的调用也正常
开始动手改造
原理
-
在当前版本依赖下,feign构造client的源码是以下这样的(我会把注释标在代码里)
-
<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)); }
-
-
从上面的源码得知,因为有url,所以open feign会忽略你隐入的loadbalancer,直接把默认的client取出来使用
-
从这个地方可以切入,通过构造我们自己的feign client返回给feign builder
-
我们自己的feign client(这里包括下文都称为proxy feign client)需要包括什么功能呢?
- 通过读取当前spring环境的配置,来决定使用url进行访问,还是使用name去获取ip:port来访问
-
-
如何把我们构造proxy feign client塞给builder呢?
-
这里有一些点要注意,因为open feign的可拓展性,支持开发者自定义client的类型,也支持是否使用loadbalancer能力的client
-
基于上述的点,我们不能直接通过Configuration的形式去返回一个client去代替本来的client
- 如果我们使用这种方式的话,那么其他上下文会因为条件注解忽略掉本来的client
-
如何解决呢?
-
动态代理去改变FeignContext获取client的逻辑
- FeignContext会根据上下文,条件注解等来综合获取一个client
- 利用动态代理,去包装这个获取的行为,构造一个proxy feign client
-
-
-
开始上刺刀
-
注入一个BeanPostProcessor,监听FeignContext的初始化行为
-
// 注入BeanPostProcessor @Configuration public class ExampleConfiguration { @Bean public BeanPostProcessor FeignContextBeanPostProcessor() { return new FeignContextBeanPostProcessor(); } }
-
// 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(); } }
-
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; } }
-
-
简单的ProxyClient
-
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); } }
-
-
写在最后
- 如果有需要转载,请附上原地址,谢谢啦
- 感谢各位大佬的浏览,有什么想法意见,或者文中有什么错误的地方,留言评论!