⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 码农参上 「Dr Hydra」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

在平常的工作中,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属于纯二层的两个不同网段,不能互访,所以无法直接调用。

那么,如果想在调试时把请求稳定打到本地服务的话,有一个办法,就是指定在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原理

简单来说,就是项目中加的@EnableFeignClients这个注解,实现时有一行很重要的代码:

@Import(FeignClientsRegistrar.class)

这个类实现了ImportBeanDefinitionRegistrar接口,在这个接口的registerBeanDefinitions方法中,可以手动创建BeanDefinition并注册,之后spring会根据BeanDefinition实例化生成bean,并放入容器中。

Feign就是通过这种方式,扫描添加了@FeignClient注解的接口,然后一步步生成代理对象,具体流程可以看一下下面这张图:

后续在请求时,通过代理对象的FeignInvocationHandler进行拦截,并根据对应方法进行处理器的分发,完成后续的http请求操作。

ImportBeanDefinitionRegistrar

上面提到的ImportBeanDefinitionRegistrar,在整个创建FeignClient的代理过程中非常重要, 所以我们先写一个简单的例子看一下它的用法。先定义一个实体类:

@Data
@AllArgsConstructor
public 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
@RequiredArgsConstructor
public 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客户端。

简单看一下,这个过程中还需要配置ClientEncoderDecoderContractRequestInterceptor等内容。

  • Client:实际http请求的发起者,如果不涉及负载均衡可以使用简单的Client.Default,用到负载均衡则可以使用LoadBalancerFeignClient,前面也说了,LoadBalancerFeignClient中的delegate其实使用的也是Client.Default
  • EncoderDecoder:Feign的编解码器,在spring项目中使用对应的SpringEncoderResponseEntityDecoder,这个过程中我们借用GsonHttpMessageConverter作为消息转换器来解析json
  • RequestInterceptor:Feign的拦截器,一般业务用途比较多,比如添加修改header信息等,这里用不到可以不配
  • Contract:字面意思是合约,它的作用是将我们传入的接口进行解析验证,看注解的使用是否符合规范,然后将关于http的元数据抽取成结果并返回。如果我们使用RequestMappingPostMappingGetMapping之类注解的话,那么对应使用的是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的源码中已经实现好了很多基础的功能,比如扫扫描包、获取FeignClientnamecontextIdurl等等,所以需要改动的地方非常少,可以放心的大抄特超它的代码。

先创建LocalFeignClientRegistrar,并注入需要用到的ResourceLoaderBeanFactoryEnvironment

@Slf4j
public 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注解是否被添加在接口上的测试。下面这段代码基本上是照搬源码,除了改动一下扫描包的路径,使用我们自己在配置文件中配置的包名。

@Override
public 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拿到了我们在前面创建的ClientEncoderDecoderContract,用来构建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的扩展点。

组件代码已提交到我的github,有需要的小伙伴们可以自取,码字不易,也欢迎大家点个star~

https://github.com/trunks2008/feign-local-enhancer

文章目录
  1. 1. 先看问题
  2. 2. Feign原理
  3. 3. ImportBeanDefinitionRegistrar
  4. 4. 改造
  5. 5. 测试
  6. 6. 总结