哈喽咱们好啊,我是Hydra。

在往常的作业中,OpenFeign作为微服务间的调用组件运用的非常遍及,接口合作注解的调用办法突出一个简洁,让咱们能无需关注内部细节就能完成服务间的接口调用。

可是作业中用久了,发现Feign也有些运用起来费事的当地,下面先来看一个问题,再看看咱们在作业中是怎样解决,以达到简化Feign运用的目的。

先看问题

在一个项目开发的过程中,咱们通常会区别开发环境、测验环境和出产环境,假如有的项目要求更高的话,或许还会有个预出产环境。

开发环境作为和前端开发联调的环境,一般运用起来都比较随意,而咱们在进行本地开发的时候,有时候也会将本地发动的微服务注册到注册中心nacos上,便利进行调试。

这样,注册中心的一个微服务或许就会具有多个服务实例,就像下面这样:

简化本地Feign调用,老手教你这么玩

眼尖的小伙伴肯定发现了,这两个实例的ip地址有一点不同。

线上环境现在一般运用容器化布置,通常都是由流水线工具打成镜像然后扔到docker中运转,因此咱们去看一下服务在docker容器内的ip:

简化本地Feign调用,老手教你这么玩

能够看到,这便是注册到nacos上的服务地址之一,而列表中192最初的另一个ip,则是咱们本地发动的服务的局域网地址。看一下下面这张图,就能对整个流程一望而知了。

简化本地Feign调用,老手教你这么玩

总结一下:

  • 两个service都是经过宿主机的ip和port,把自己的信息注册到nacos上
  • 线上环境的service注册时运用docker内部ip地址
  • 本地的service注册时运用本地局域网地址

那么这时候问题就来了,当我本地再发动一个serviceB,经过FeignClient来调用serviceA中的接口时,由于Feign本身的负载均衡,就或许把恳求负载均衡到两个不同的serviceA实例。

假如这个调用恳求被负载均衡到本地serviceA的话,那么没什么问题,两个服务都在同一个192.168网段内,能够正常拜访。可是假如负载均衡恳求到运转在docker内的serviceA的话,那么问题来了,由于网络不通,所以会恳求失败:

简化本地Feign调用,老手教你这么玩

说白了,便是本地的192.168和docker内的虚拟网段172.17属于纯二层的两个不同网段,不能互访,所以无法直接调用。

那么,假如想在调试时把恳求安稳打到本地服务的话,有一个办法,便是指定在FeignClient中增加url参数,指定调用的地址:

@FeignClient(value = "hydra-service",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注解的接口,然后一步步生成署理目标,详细流程能够看一下下面这张图:

简化本地Feign调用,老手教你这么玩

后续在恳求时,经过署理目标的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的办法。

docs.spring.io/spring-clou…

简略来说,便是咱们能够像下面这样,经过Feign的Builder API来手动创立一个Feign客户端。

简化本地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();
}

发动服务,过程中能够看见了执行扫描包的操作:

简化本地Feign调用,老手教你这么玩

在替换url过程中增加一个断点,能够看到即使在注解中装备了url,也会优先被装备文件中的服务url覆盖:

简化本地Feign调用,老手教你这么玩

运用接口进行测验,能够看到运用上面的署理目标进行了拜访并成功返回了成果:

简化本地Feign调用,老手教你这么玩

假如项目需求发布正式环境,只需求将装备feign.local.enable改为false或删掉,并在项目中增加Feign原始的@EnableFeignClients即可。

总结

本文供给了一个在本地开发过程中简化Feign调用的思路,比较之前需求费事的修正FeignClient中的url而言,能够节约不少的无效劳动,而且经过这个过程,也能够协助咱们了解咱们往常运用的这些组件是怎样与spring结合在一起的,熟悉spring的扩展点。

组件代码已提交到我的github,有需求的小伙伴们能够自取,码字不易,也欢迎咱们点个star~

github.com/trunks2008/…

那么,这次的共享就到这儿,我是Hydra,咱们下篇再会。


作者简介,码农参上,一个热爱共享的公众号,有趣、深化、直接,与你聊聊技能。欢迎关注、增加好友,进一步沟通。