在平常的工作中,OpenFeign
作为微服务间的调用组件使用的非常普遍,接口配合注解的调用方式突出一个简便,让我们能无需关注内部细节就能实现服务间的接口调用。
但是工作中用久了,发现Feign也有些使用起来麻烦的地方,下面先来看一个问题,再看看我们在工作中是如何解决,以达到简化Feign使用的目的。
先看问题
在一个项目开发的过程中,我们通常会区分开发环境、测试环境和生产环境,如果有的项目要求更高的话,可能还会有个预生产环境。
开发环境作为和前端开发联调的环境,一般使用起来都比较随意,而我们在进行本地开发的时候,有时候也会将本地启动的微服务注册到注册中心nacos上,方便进行调试。
这样,注册中心的一个微服务可能就会拥有多个服务实例,就像下面这样:
眼尖的小伙伴肯定发现了,这两个实例的ip地址有一点不同。
线上环境现在一般使用容器化部署,通常都是由流水线工具打成镜像然后扔到docker中运行,因此我们去看一下服务在docker容器内的ip:
可以看到,这就是注册到nacos上的服务地址之一,而列表中192
开头的另一个ip,则是我们本地启动的服务的局域网地址。看一下下面这张图,就能对整个流程一目了然了。
总结一下:
两个service都是通过宿主机的ip和port,把自己的信息注册到nacos上 线上环境的service注册时使用docker内部ip地址 本地的service注册时使用本地局域网地址
那么这时候问题就来了,当我本地再启动一个serviceB,通过FeignClient
来调用serviceA中的接口时,因为Feign本身的负载均衡,就可能把请求负载均衡到两个不同的serviceA实例。
如果这个调用请求被负载均衡到本地serviceA的话,那么没什么问题,两个服务都在同一个192.168
网段内,可以正常访问。但是如果负载均衡请求到运行在docker内的serviceA的话,那么问题来了,因为网络不通,所以会请求失败:
说白了,就是本地的192.168
和docker内的虚拟网段172.17
属于纯二层的两个不同网段,不能互访,所以无法直接调用。关注公众号:码猿技术专栏,回复关键词:1111 获取阿里内部Java性能调优手册!
那么,如果想在调试时把请求稳定打到本地服务的话,有一个办法,就是指定在FeignClient
中添加url
参数,指定调用的地址:
@FeignClient(value = "serviceA",url = "http://127.0.0.1:8088/")public interface ClientA { @GetMapping("/test/get") String get();}
但是这么一来也会带来点问题:
代码上线时需要再把注解中的 url
删掉,还要再次修改代码,如果忘了的话会引起线上问题如果测试的 FeignClient
很多的话,每个都需要配置url
,修改起来很麻烦
那么,有什么办法进行改进呢?为了解决这个问题,我们还是得从Feign的原理说起。
Feign原理
Feign的实现和工作原理,我以前写过一篇简单的源码分析,大家可以简单花个几分钟先铺垫一下,Feign核心源码解析。明白了原理,后面理解起来更方便一些。
简单来说,就是项目中加的@EnableFeignClients
这个注解,实现时有一行很重要的代码:
@Import(FeignClientsRegistrar.class)
这个类实现了ImportBeanDefinitionRegistrar
接口,在这个接口的registerBeanDefinitions
方法中,可以手动创建BeanDefinition
并注册,之后spring会根据BeanDefinition
实例化生成bean,并放入容器中。
Feign就是通过这种方式,扫描添加了@FeignClient
注解的接口,然后一步步生成代理对象,具体流程可以看一下下面这张图:
后续在请求时,通过代理对象的FeignInvocationHandler
进行拦截,并根据对应方法进行处理器的分发,完成后续的http请求操作。
ImportBeanDefinitionRegistrar
上面提到的ImportBeanDefinitionRegistrar
,在整个创建FeignClient
的代理过程中非常重要, 所以我们先写一个简单的例子看一下它的用法。先定义一个实体类:
@Data@AllArgsConstructorpublic class User { Long id; String name;}
通过BeanDefinitionBuilder
,向这个实体类的构造方法中传入具体值,最后生成一个BeanDefinition
:
public class MyBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(User.class); builder.addConstructorArgValue(1L); builder.addConstructorArgValue("Hydra"); AbstractBeanDefinition beanDefinition = builder.getBeanDefinition(); registry.registerBeanDefinition(User.class.getSimpleName(),beanDefinition); }}
registerBeanDefinitions
方法的具体调用时间是在之后的ConfigurationClassPostProcessor
执行postProcessBeanDefinitionRegistry
方法时,而registerBeanDefinition
方法则会将BeanDefinition
放进一个map中,后续根据它实例化bean。
在配置类上通过@Import
将其引入:
@Configuration@Import(MyBeanDefinitionRegistrar.class)public class MyConfiguration {}
注入这个User
测试:
@Service@RequiredArgsConstructorpublic class UserService { private final User user; public void getUser(){ System.out.println(user.toString()); }}
结果打印,说明我们通过自定义BeanDefinition
的方式成功手动创建了一个bean并放入了spring容器中:
User(id=1, name=Hydra)
好了,准备工作铺垫到这结束,下面开始正式的改造工作。
改造
到这里先总结一下,我们纠结的点就是本地环境需要FeignClient
中配置url
,但线上环境不需要,并且我们又不想来回修改代码。
除了像源码中那样生成动态代理以及拦截方法,官方文档中还给我们提供了一个手动创建FeignClient的方法。
https://docs.spring.io/spring-cloud-openfeign/docs/2.2.9.RELEASE/reference/html/#creating-feign-clients-manually
简单来说,就是我们可以像下面这样,通过Feign的Builder API来手动创建一个Feign客户端。
简单看一下,这个过程中还需要配置Client
、Encoder
、Decoder
、Contract
、RequestInterceptor
等内容。
Client
:实际http请求的发起者,如果不涉及负载均衡可以使用简单的Client.Default
,用到负载均衡则可以使用LoadBalancerFeignClient
,前面也说了,LoadBalancerFeignClient
中的delegate
其实使用的也是Client.Default
Encoder
和Decoder
:Feign的编解码器,在spring项目中使用对应的SpringEncoder
和ResponseEntityDecoder
,这个过程中我们借用GsonHttpMessageConverter
作为消息转换器来解析jsonRequestInterceptor
:Feign的拦截器,一般业务用途比较多,比如添加修改header信息等,这里用不到可以不配Contract
:字面意思是合约,它的作用是将我们传入的接口进行解析验证,看注解的使用是否符合规范,然后将关于http的元数据抽取成结果并返回。如果我们使用RequestMapping
、PostMapping
、GetMapping
之类注解的话,那么对应使用的是SpringMvcContract
其实这里刚需的就只有Contract
这一个,其他都是可选的配置项。我们写一个配置类,把这些需要的东西都注入进去:
@Slf4j@Configuration(proxyBeanMethods = false)@EnableConfigurationProperties({LocalFeignProperties.class})@Import({LocalFeignClientRegistrar.class})@ConditionalOnProperty(value = "feign.local.enable", havingValue = "true")public class FeignAutoConfiguration { static { log.info("feign local route started"); } @Bean @Primary public Contract contract(){ return new SpringMvcContract(); } @Bean(name = "defaultClient") public Client defaultClient(){ return new Client.Default(null,null); } @Bean(name = "ribbonClient") public Client ribbonClient(CachingSpringLoadBalancerFactory cachingFactory, SpringClientFactory clientFactory){ return new LoadBalancerFeignClient(defaultClient(), cachingFactory, clientFactory); } @Bean public Decoder decoder(){ HttpMessageConverter httpMessageConverter=new GsonHttpMessageConverter(); ObjectFactory<HttpMessageConverters> messageConverters= () -> new HttpMessageConverters(httpMessageConverter); SpringDecoder springDecoder = new SpringDecoder(messageConverters); return new ResponseEntityDecoder(springDecoder); } @Bean public Encoder encoder(){ HttpMessageConverter httpMessageConverter=new GsonHttpMessageConverter(); ObjectFactory<HttpMessageConverters> messageConverters= () -> new HttpMessageConverters(httpMessageConverter); return new SpringEncoder(messageConverters); }}
在这个配置类上,还有三行注解,我们一点点解释。
首先是引入的配置类LocalFeignProperties
,里面有三个属性,分别是是否开启本地路由的开关、扫描FeignClient接口的包名,以及我们要做的本地路由映射关系,addressMapping
中存的是服务名和对应的url地址:
@Data@Component@ConfigurationProperties(prefix = "feign.local")public class LocalFeignProperties { // 是否开启本地路由 private String enable; //扫描FeignClient的包名 private String basePackage; //路由地址映射 private Map<String,String> addressMapping;}
下面这行注解则表示只有当配置文件中feign.local.enable
这个属性为true
时,才使当前配置文件生效:
@ConditionalOnProperty(value = "feign.local.enable", havingValue = "true")
最后,就是我们重中之重的LocalFeignClientRegistrar
了,我们还是按照官方通过ImportBeanDefinitionRegistrar
接口构建BeanDefinition
然后注册的思路来实现。
并且,FeignClientsRegistrar
的源码中已经实现好了很多基础的功能,比如扫扫描包、获取FeignClient
的name
、contextId
、url
等等,所以需要改动的地方非常少,可以放心的大抄特超它的代码。
先创建LocalFeignClientRegistrar
,并注入需要用到的ResourceLoader
、BeanFactory
、Environment
。
@Slf4jpublic class LocalFeignClientRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware, BeanFactoryAware{ private ResourceLoader resourceLoader; private BeanFactory beanFactory; private Environment environment; @Override public void setResourceLoader(ResourceLoader resourceLoader) { this.resourceLoader=resourceLoader; } @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.beanFactory = beanFactory; } @Override public void setEnvironment(Environment environment) { this.environment=environment; } //先省略具体功能代码...}
然后看一下创建BeanDefinition
前的工作,这一部分主要完成了包的扫描和检测@FeignClient
注解是否被添加在接口上的测试。下面这段代码基本上是照搬源码,除了改动一下扫描包的路径,使用我们自己在配置文件中配置的包名。
@Overridepublic void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { ClassPathScanningCandidateComponentProvider scanner = ComponentScanner.getScanner(environment); scanner.setResourceLoader(resourceLoader); AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(FeignClient.class); scanner.addIncludeFilter(annotationTypeFilter); String basePackage =environment.getProperty("feign.local.basePackage"); log.info("begin to scan {}",basePackage); Set<BeanDefinition> candidateComponents = scanner.findCandidateComponents(basePackage); for (BeanDefinition candidateComponent : candidateComponents) { if (candidateComponent instanceof AnnotatedBeanDefinition) { log.info(candidateComponent.getBeanClassName()); // 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 = FeignCommonUtil.getClientName(attributes); registerFeignClient(registry, annotationMetadata, attributes); } }}
接下来创建BeanDefinition
并注册,Feign的源码中是使用的FeignClientFactoryBean
创建代理对象,这里我们就不需要了,直接替换成使用Feign.builder
创建。
private void registerFeignClient(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata, Map<String, Object> attributes) { String className = annotationMetadata.getClassName(); Class clazz = ClassUtils.resolveClassName(className, null); ConfigurableBeanFactory beanFactory = registry instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory) registry : null; String contextId = FeignCommonUtil.getContextId(beanFactory, attributes,environment); String name = FeignCommonUtil.getName(attributes,environment); BeanDefinitionBuilder definition = BeanDefinitionBuilder .genericBeanDefinition(clazz, () -> { Contract contract = beanFactory.getBean(Contract.class); Client defaultClient = (Client) beanFactory.getBean("defaultClient"); Client ribbonClient = (Client) beanFactory.getBean("ribbonClient"); Encoder encoder = beanFactory.getBean(Encoder.class); Decoder decoder = beanFactory.getBean(Decoder.class); LocalFeignProperties properties = beanFactory.getBean(LocalFeignProperties.class); Map<String, String> addressMapping = properties.getAddressMapping(); Feign.Builder builder = Feign.builder() .encoder(encoder) .decoder(decoder) .contract(contract); String serviceUrl = addressMapping.get(name); String originUrl = FeignCommonUtil.getUrl(beanFactory, attributes, environment); Object target; if (StringUtils.hasText(serviceUrl)){ target = builder.client(defaultClient) .target(clazz, serviceUrl); }else if (StringUtils.hasText(originUrl)){ target = builder.client(defaultClient) .target(clazz,originUrl); }else { target = builder.client(ribbonClient) .target(clazz,"http://"+name); } return target; }); definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); definition.setLazyInit(true); FeignCommonUtil.validate(attributes); AbstractBeanDefinition beanDefinition = definition.getBeanDefinition(); beanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, className); // has a default, won't be null boolean primary = (Boolean) attributes.get("primary"); beanDefinition.setPrimary(primary); String[] qualifiers = FeignCommonUtil.getQualifiers(attributes); if (ObjectUtils.isEmpty(qualifiers)) { qualifiers = new String[] { contextId + "FeignClient" }; } BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, qualifiers); BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);}
在这个过程中主要做了这么几件事:
通过 beanFactory
拿到了我们在前面创建的Client
、Encoder
、Decoder
、Contract
,用来构建Feign.Builder
通过注入配置类,通过 addressMapping
拿到配置文件中服务对应的调用url
通过 target
方法替换要请求的url
,如果配置文件中存在则优先使用配置文件中url
,否则使用@FeignClient
注解中配置的url
,如果都没有则使用服务名通过LoadBalancerFeignClient
访问
在resources/META-INF
目录下创建spring.factories
文件,通过spi注册我们的自动配置类:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.feign.local.config.FeignAutoConfiguration
最后,本地打包即可:
mvn clean install
测试
引入我们在上面打好的包,由于包中已经包含了spring-cloud-starter-openfeign
,所以就不需要再额外引feign
的包了:
<dependency> <groupId>com.cn.hydra</groupId> <artifactId>feign-local-enhancer</artifactId> <version>1.0-SNAPSHOT</version></dependency>
在配置文件中添加配置信息,启用组件:
feign: local: enable: true basePackage: com.service addressMapping: hydra-service: http://127.0.0.1:8088 trunks-service: http://127.0.0.1:8099
创建一个FeignClient
接口,注解的url
中我们可以随便写一个地址,可以用来测试之后是否会被配置文件中的服务地址覆盖:
@FeignClient(value = "hydra-service", contextId = "hydra-serviceA", url = "http://127.0.0.1:8099/")public interface ClientA { @GetMapping("/test/get") String get(); @GetMapping("/test/user") User getUser();}
启动服务,过程中可以看见了执行扫描包的操作:
在替换url
过程中添加一个断点,可以看到即使在注解中配置了url
,也会优先被配置文件中的服务url
覆盖:
使用接口进行测试,可以看到使用上面的代理对象进行了访问并成功返回了结果:
如果项目需要发布正式环境,只需要将配置feign.local.enable
改为false
或删掉,并在项目中添加Feign原始的@EnableFeignClients
即可。
总结
本文提供了一个在本地开发过程中简化Feign调用的思路,相比之前需要麻烦的修改FeignClient
中的url
而言,能够节省不少的无效劳动,并且通过这个过程,也可以帮助大家了解我们平常使用的这些组件是怎么与spring结合在一起的,熟悉spring的扩展点。
往期推荐