簡化本地Feign調(diào)用,老手教你這么玩
哈嘍大家好啊,我是Hydra。
在平常的工作中,OpenFeign作為微服務(wù)間的調(diào)用組件使用的非常普遍,接口配合注解的調(diào)用方式突出一個簡便,讓我們能無需關(guān)注內(nèi)部細節(jié)就能實現(xiàn)服務(wù)間的接口調(diào)用。
但是工作中用久了,發(fā)現(xiàn)Feign也有些使用起來麻煩的地方,下面先來看一個問題,再看看我們在工作中是如何解決,以達到簡化Feign使用的目的。
先看問題
在一個項目開發(fā)的過程中,我們通常會區(qū)分開發(fā)環(huán)境、測試環(huán)境和生產(chǎn)環(huán)境,如果有的項目要求更高的話,可能還會有個預(yù)生產(chǎn)環(huán)境。
開發(fā)環(huán)境作為和前端開發(fā)聯(lián)調(diào)的環(huán)境,一般使用起來都比較隨意,而我們在進行本地開發(fā)的時候,有時候也會將本地啟動的微服務(wù)注冊到注冊中心nacos上,方便進行調(diào)試。
這樣,注冊中心的一個微服務(wù)可能就會擁有多個服務(wù)實例,就像下面這樣:
眼尖的小伙伴肯定發(fā)現(xiàn)了,這兩個實例的ip地址有一點不同。
線上環(huán)境現(xiàn)在一般使用容器化部署,通常都是由流水線工具打成鏡像然后扔到docker中運行,因此我們?nèi)タ匆幌路?wù)在docker容器內(nèi)的ip:
可以看到,這就是注冊到nacos上的服務(wù)地址之一,而列表中192開頭的另一個ip,則是我們本地啟動的服務(wù)的局域網(wǎng)地址。看一下下面這張圖,就能對整個流程一目了然了。
總結(jié)一下:
- 兩個service都是通過宿主機的ip和port,把自己的信息注冊到nacos上
- 線上環(huán)境的service注冊時使用docker內(nèi)部ip地址
- 本地的service注冊時使用本地局域網(wǎng)地址
那么這時候問題就來了,當(dāng)我本地再啟動一個serviceB,通過FeignClient來調(diào)用serviceA中的接口時,因為Feign本身的負載均衡,就可能把請求負載均衡到兩個不同的serviceA實例。
如果這個調(diào)用請求被負載均衡到本地serviceA的話,那么沒什么問題,兩個服務(wù)都在同一個192.168網(wǎng)段內(nèi),可以正常訪問。但是如果負載均衡請求到運行在docker內(nèi)的serviceA的話,那么問題來了,因為網(wǎng)絡(luò)不通,所以會請求失?。?/p>
說白了,就是本地的192.168和docker內(nèi)的虛擬網(wǎng)段172.17屬于純二層的兩個不同網(wǎng)段,不能互訪,所以無法直接調(diào)用。
那么,如果想在調(diào)試時把請求穩(wěn)定打到本地服務(wù)的話,有一個辦法,就是指定在FeignClient中添加url參數(shù),指定調(diào)用的地址:
@FeignClient(value = "serviceA",url = "http://127.0.0.1:8088/")
public interface ClientA {
@GetMapping("/test/get")
String get();
}
但是這么一來也會帶來點問題:
- 代碼上線時需要再把注解中的url刪掉,還要再次修改代碼,如果忘了的話會引起線上問題。
- 如果測試的FeignClient很多的話,每個都需要配置url,修改起來很麻煩
那么,有什么辦法進行改進呢?為了解決這個問題,我們還是得從Feign的原理說起。。
Feign原理
Feign的實現(xiàn)和工作原理,我以前寫過一篇簡單的源碼分析,大家可以簡單花個幾分鐘先鋪墊一下,F(xiàn)eign核心源碼解析。明白了原理,后面理解起來更方便一些。
簡單來說,就是項目中加的@EnableFeignClients這個注解,實現(xiàn)時有一行很重要的代碼:
@Import(FeignClientsRegistrar.class)
這個類實現(xiàn)了ImportBeanDefinitionRegistrar接口,在這個接口的registerBeanDefinitions方法中,可以手動創(chuàng)建BeanDefinition并注冊,之后spring會根據(jù)BeanDefinition實例化生成bean,并放入容器中。
Feign就是通過這種方式,掃描添加了@FeignClient注解的接口,然后一步步生成代理對象,具體流程可以看一下下面這張圖:
后續(xù)在請求時,通過代理對象的FeignInvocationHandler進行攔截,并根據(jù)對應(yīng)方法進行處理器的分發(fā),完成后續(xù)的http請求操作。
ImportBeanDefinitionRegistrar
上面提到的ImportBeanDefinitionRegistrar,在整個創(chuàng)建FeignClient的代理過程中非常重要, 所以我們先寫一個簡單的例子看一下它的用法。先定義一個實體類:
@Data
@AllArgsConstructor
public class User {
Long id;
String name;
}
通過BeanDefinitionBuilder,向這個實體類的構(gòu)造方法中傳入具體值,最后生成一個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方法的具體調(diào)用時間是在之后的ConfigurationClassPostProcessor執(zhí)行postProcessBeanDefinitionRegistry方法時,而registerBeanDefinition方法則會將BeanDefinition放進一個map中,后續(xù)根據(jù)它實例化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());
}
}
結(jié)果打印,說明我們通過自定義BeanDefinition的方式成功手動創(chuàng)建了一個bean并放入了spring容器中:
User(id=1, name=Hydra)
好了,準(zhǔn)備工作鋪墊到這結(jié)束,下面開始正式的改造工作。
改造
到這里先總結(jié)一下,我們糾結(jié)的點就是本地環(huán)境需要FeignClient中配置url,但線上環(huán)境不需要,并且我們又不想來回修改代碼。
除了像源碼中那樣生成動態(tài)代理以及攔截方法,官方文檔中還給我們提供了一個手動創(chuàng)建FeignClient的方法。
https://docs.spring.io/spring-cloud-openfeign/docs/2.2.9.RELEASE/reference/html/#creating-feign-clients-manually
簡單來說,就是我們可以像下面這樣,通過Feign的Builder API來手動創(chuàng)建一個Feign客戶端。
簡單看一下,這個過程中還需要配置Client、Encoder、Decoder、Contract、RequestInterceptor等內(nèi)容。
- Client:實際http請求的發(fā)起者,如果不涉及負載均衡可以使用簡單的Client.Default,用到負載均衡則可以使用LoadBalancerFeignClient,前面也說了,LoadBalancerFeignClient中的delegate其實使用的也是Client.Default。
- Encoder和Decoder:Feign的編解碼器,在spring項目中使用對應(yīng)的SpringEncoder和ResponseEntityDecoder,這個過程中我們借用GsonHttpMessageConverter作為消息轉(zhuǎn)換器來解析json。
- RequestInterceptor:Feign的攔截器,一般業(yè)務(wù)用途比較多,比如添加修改header信息等,這里用不到可以不配。
- Contract:字面意思是合約,它的作用是將我們傳入的接口進行解析驗證,看注解的使用是否符合規(guī)范,然后將關(guān)于http的元數(shù)據(jù)抽取成結(jié)果并返回。如果我們使用RequestMapping、PostMapping、GetMapping之類注解的話,那么對應(yīng)使用的是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 httpMessageCnotallow=new GsonHttpMessageConverter();
ObjectFactory<HttpMessageConverters> messageCnotallow= () -> new HttpMessageConverters(httpMessageConverter);
SpringDecoder springDecoder = new SpringDecoder(messageConverters);
return new ResponseEntityDecoder(springDecoder);
}
@Bean
public Encoder encoder(){
HttpMessageConverter httpMessageCnotallow=new GsonHttpMessageConverter();
ObjectFactory<HttpMessageConverters> messageCnotallow= () -> new HttpMessageConverters(httpMessageConverter);
return new SpringEncoder(messageConverters);
}
}
在這個配置類上,還有三行注解,我們一點點解釋。
首先是引入的配置類LocalFeignProperties,里面有三個屬性,分別是是否開啟本地路由的開關(guān)、掃描FeignClient接口的包名,以及我們要做的本地路由映射關(guān)系,addressMapping中存的是服務(wù)名和對應(yīng)的url地址:
@Data
@Component
@ConfigurationProperties(prefix = "feign.local")
public class LocalFeignProperties {
// 是否開啟本地路由
private String enable;
//掃描FeignClient的包名
private String basePackage;
//路由地址映射
private Map<String,String> addressMapping;
}
下面這行注解則表示只有當(dāng)配置文件中feign.local.enable這個屬性為true時,才使當(dāng)前配置文件生效:
@ConditionalOnProperty(value = "feign.local.enable", havingValue = "true")
最后,就是我們重中之重的LocalFeignClientRegistrar了,我們還是按照官方通過ImportBeanDefinitionRegistrar接口構(gòu)建BeanDefinition然后注冊的思路來實現(xiàn)。
并且,F(xiàn)eignClientsRegistrar的源碼中已經(jīng)實現(xiàn)好了很多基礎(chǔ)的功能,比如掃掃描包、獲取FeignClient的name、contextId、url等等,所以需要改動的地方非常少,可以放心的大抄特超它的代碼。
先創(chuàng)建LocalFeignClientRegistrar,并注入需要用到的ResourceLoader、BeanFactory、Environment。
@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.envirnotallow=environment;
}
//先省略具體功能代碼...
}
然后看一下創(chuàng)建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);
}
}
}
接下來創(chuàng)建BeanDefinition并注冊,F(xiàn)eign的源碼中是使用的FeignClientFactoryBean創(chuàng)建代理對象,這里我們就不需要了,直接替換成使用Feign.builder創(chuàng)建。
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拿到了我們在前面創(chuàng)建的Client、Encoder、Decoder、Contract,用來構(gòu)建Feign.Builder。
- 通過注入配置類,通過addressMapping拿到配置文件中服務(wù)對應(yīng)的調(diào)用url。
- 通過target方法替換要請求的url,如果配置文件中存在則優(yōu)先使用配置文件中url,否則使用@FeignClient注解中配置的url,如果都沒有則使用服務(wù)名通過LoadBalancerFeignClient訪問。
在resources/META-INF目錄下創(chuàng)建spring.factories文件,通過spi注冊我們的自動配置類:
org.springframework.boot.autoconfigure.EnableAutoCnotallow=\
com.feign.local.config.FeignAutoConfiguration
最后,本地打包即可:
mvn clean install
測試
引入我們在上面打好的包,由于包中已經(jīng)包含了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
創(chuàng)建一個FeignClient接口,注解的url中我們可以隨便寫一個地址,可以用來測試之后是否會被配置文件中的服務(wù)地址覆蓋:
@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();
}
啟動服務(wù),過程中可以看見了執(zhí)行掃描包的操作:
在替換url過程中添加一個斷點,可以看到即使在注解中配置了url,也會優(yōu)先被配置文件中的服務(wù)url覆蓋:
使用接口進行測試,可以看到使用上面的代理對象進行了訪問并成功返回了結(jié)果:
如果項目需要發(fā)布正式環(huán)境,只需要將配置feign.local.enable改為false或刪掉,并在項目中添加Feign原始的@EnableFeignClients即可。
總結(jié)
本文提供了一個在本地開發(fā)過程中簡化Feign調(diào)用的思路,相比之前需要麻煩的修改FeignClient中的url而言,能夠節(jié)省不少的無效勞動,并且通過這個過程,也可以幫助大家了解我們平常使用的這些組件是怎么與spring結(jié)合在一起的,熟悉spring的擴展點。
組件代碼已提交到我的github,有需要的小伙伴們可以自取。
https://github.com/trunks2008/feign-local-enhancer。