背景
因为一项需求,我需要为Feign的Client做一套代理。代理嘛,并不难:
/**
* 代理类。
* 当然,严格来说这是个装饰者模式。
* 不过,实践中谁还严格区分这个呢。
*/
public class MyFeignClient implements Client{
private final Client delegate;
public MyFeignClient(Client delegate){
this.delegate = delegate;
}
@Override
public Response execute(Request request, Request.Options options) throws IOException{
// 做一些业务逻辑,然后用delgate做正常的处理
return delegate.execute(request, options);
}
}
/** 借助BeanPostProcessor,把这个对象放到SpringIoC上下文中去 */
public class MyFeignClientProcessor implements BeanPostProcessor{
@Override
public Object postProcessAfterInitialization(Object bean, String beanName){
if(bean instanceof Client){
return new MyFeignClient((Client)bean);
}else{
return bean;
}
}
}
声明一下,代理类中扩展的业务逻辑与负载均衡毫无关系。
看起来没问题。写完之后,启动服务,调用一下……抛异常了。
问题
出问题的@FeignClient注解是这样配置的:
@FeignClient(name="RemoteServer", url="172.16.xxx.xxx")
public interface RemoteService{
@GetMapping("/query")
RemoteResp query(@RequestParam("id") long id);
}
调用代码与问题无关,就不贴了。
我这里配置了@FeignClient注解的url参数,主要有两个原因。
主要原因是在生产环境上,为了调用未接入eureka的老系统,我们需要把url参数配置为该系统的网关地址。其次,在开发联调阶段,为了确保请求发送到联调方的开发环境上,我们也会配置url参数。
总之,配置url参数是个实打实的需求,不能忽视。
然而,配置了url参数之后,一调用这个接口就会抛出异常。直接拉到异常栈的最底层,可以看到这样的信息:
Caused by: com.netfix.client.clientException: Load balancer does not have available server for client : 172.16.xxx.xxx
at com.netflix.loadbalancer.LoadBalancerContext.getServerFromLoadBalancer(LoadBalancerContext.java:483)
at com.netflix.loadbalancer.reactive.LoadBalancerCommandsl.call(LoadBalancerCommand.java:184)
粗略一看,“这个妹妹我见过的”。如果调用方和服务方没有注册到同一个eureka上,就会出现“Load balancer does not have available server for client”这个异常。这问题也很好解决,只需要统一调用方和服务方的eureka配置就好了嘛!
再仔细看看……奇怪了……
分析
奇怪的异常
这段异常有两个地方很奇怪。
首先,我明明在@FeignClient注解上配置了name参数,为什么出现在报错信息里的是注解中的url参数值呢? 正常情况下,这里应该是注解中的name参数值,就像这样才对啊:
Caused by: com.netfix.client.clientException:Load balancer does not have available server for client :
RemoteServer
at com.netflix.loadbalancer.LoadBalancerContext.getServerFromLoadBalancer(LoadBalancerContext.java:483)
at com.netflix.loadbalancer.reactive.LoadBalancerCommandsl.call(LoadBalancerCommand.java:184)
可是这次的异常信息,却是这样的:
Caused by: com.netfix.client.clientException: Load balancer does not have available server for client :
172.16.xxx.xxx
at com.netflix.loadbalancer.LoadBalancerContext.getServerFromLoadBalancer(LoadBalancerContext.java:483) at com.netflix.loadbalancer.reactive.LoadBalancerCommandsl.call(LoadBalancerCommand.java:184)
其次,我明明在注解上配置了url参数,为什么还会抛出软负载相关的异常呢?
众所周知,如果为@FeignClient注解配置了非空的url参数,Feign就会直接访问url指定地址,而不会触发本地软负载相关逻辑。
可是异常信息却明明白白地表明,问题出现在软负载相关代码中:
Caused by: com.netfix.client.clientException: Load balancer does not have available server for client : 172.16.xxx.xxx
atcom.netflix.loadbalancer.LoadBalancerContext.getServerFromLoadBalancer(LoadBalancerContext.java:483)
atcom.netflix.loadbalancer.reactive.LoadBalancerCommandsl.call(LoadBalancerCommand.java:184)
该用name参数的时候没用name参数;不该做软负载的时候做了软负载。奇怪,真是奇怪。
没头绪的代码
想了半天没有头绪,只好从Feign框架中代码处下手了。看来看去,在FeignClientFacotryBean中看到这样一段代码:
public class FeignClientFactoryBean{
<T> T getTarget() {
FeignContext context = this.applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
if (!StringUtils.hasText(this.url)) {
if (!this.name.startsWith("http")) {
this.url = "http://" + this.name;
}
else {
this.url = this.name;
}
this.url += cleanPath();
return (T) loadBalance(builder, context,
new HardCodedTarget<>(this.type, this.name, this.url));
}
if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
this.url = "http://" + this.url;
}
String url = this.url + cleanPath();
// 代码位置1
Client client = getOptional(context, Client.class);
if (client != null) {
if (client instanceof LoadBalancerFeignClient) {
// not load balancing because we have a url,
// but ribbon is on the classpath, so unwrap
client = ((LoadBalancerFeignClient) client).getDelegate();
}
builder.client(client);
}
Targeter targeter = get(context, Targeter.class);
return (T) targeter.target(this, builder, context,
new HardCodedTarget<>(this.type, this.name, url));
}
// 其它代码略
}
由于我们配置了url参数,所以这里会跳过“if (!StringUtils.hasText(this.url)) ”相关处理。因此,我们可以直接从“代码位置1”开始。
在debug模式下可以看到,在“Client client = getOptional(context, Client.class);”这一行,获取到的确实是MyFeignClient实例。继续往下执行,在“if (client instanceof LoadBalancerFeignClient)”处,显然判断结果为false,可以跳过这个if块。再继续执行……咦,后面的逻辑好像都和负载均衡无关啊。
那这个异常是从哪儿抛出来的?没头绪,实在没头绪。
无语的原因
没头绪,只好再捋一捋FeignClientFactoryBean中的代码。
"if (!StringUtils.hasText(this.url))"……嗯……"if(client != null)"……嗯……“if (client instanceof LoadBalancerFeignClient)”……嗯?"if (client instanceof LoadBalancerFeignClient)"?
为什么这里要针对LoadBalancerFeignClient做一个特殊处理呢?
如果没有通过"if (!StringUtils.hasText(this.url)) "判断,说明@FeignClient注解中配置了url参数。配置了url参数时,就应该按照url指定的地址进行调用,而不应该再做软负载相关处理。软负载的功能恰恰由LoadBalancerFeignClient提供。不想调用软负载逻辑的话,就必须把LoadBalancerFeignClient中的delegate剥离出来,用这个不带软负载功能的对象来发送请求。这就是对LoadBalanceFeignClient做特殊处理的目的。
想到这里,我突然灵光一闪:既然这里判断了"if (client instanceof LoadBalancerFeignClient)",那是不是意味着,如果没有MyFeignClient,这里获取到的client可能会是LoadBalancerFeignClient?这么说的话,当MyFeignClientProcessor装配MyFeignClient时,组装到delegate中的对象,会不会也是一个LoadBalancerFeignClient实例呢?
事实果真如此。无论用debug模式、还是在MyFeignClientProcessor中增加日志,或者是查看完整的异常信息,都可以确定这一点。例如,在异常信息中就有这样两行,清楚地说明了MyFeignClient内调用的正是LoadBalancerFeignClient:
at org.springframework.cloud.openfeign.ribbon.LoadBalancerFeignClient.execute(LoadBalancerFeignClient.java:73)
at com.xxx.service.MyFeignClient.execute(MyFeignClient.java:xxx)
问题原因终于浮出了水面。
由于内部的delegate是一个LoadBalancerFeignClient对象,因而MyFeignClient也拥有了软负载能力。同时,由于无法通过"if (client instanceof LoadBalancerFeignClient)"判断,因而FeignClientFactoryBean无法将软负载能力从MyFeignClient中剥离。这样一来,系统最终得到的FeignClient实例就一定会进入软负载流程。
通过调用时序图可以将异常过程看得很清楚。当发生调用,Feign就会通过MyFeignClient调用到它代理的LoadBalanceFeignClient上。而LoadBalcanceFeignClient会使用url参数中的ip地址去做软负载,最终抛出异常。
为什么异常信息里是@FeignClient中的url而不是name,为什么指定了url却还会触发软负载逻辑,全都解释得通了。
问题查到这里,我实在是有点上火。代理的应用很广泛,尤其在SpringIoC的协助下,几乎成为了扩展框架、自定义组件的不二之选。可是instanceof却是代理的死敌。它只认特定的类及其子类,而不认不在这套继承体系内、但拥有同样的甚至更强大能力的代理类。
就好比……升职加薪的时候,老板只看你是不是xxx的亲戚,而不看你是不是有业绩、能力和证书。
在技术的世界里居然也会遇到这种只看身份、不看能力的事儿。无语,太无语了。
方案
无语归无语,问题还是要解决掉。
方案一:修改FeignClientFactoryBean
因为实在太无语,我第一反应是修改FeignClientFactoryBean。这显然行不通。
方案二:修改代理关系
第二个想法更现实些。如果我能够把MyFeignClient、LoadBalancerFeignClient和OkHttpFeignClient的代理关系调整一下,变成下图这样,不就能解决问题了么:
这个想法很好。可是当我看到LoadBalanerFeignClient和OkHttpClient之间的构建关系时……
class OkHttpFeignLoadBalancedConfiguraion{
@Bean
@ConditionalOnMissingBean(Client.class)
public Client feignClient(CachingSpringLoadBalancerFactory factory,
SpringClientFactory clientFactory,
okhttp3.OkHttpClient okHttpClient){
OkHttpClient delegate = new OkHttpClient(okHttpClient);
return new LoadBalancerFeignClient(delegate, cachingFactory, clientFactory);
}
// 其它,略
}
如果要按这个方案改,那么有两个选择。一种是在这个方法之前注入MyFeignClient:
class MyFeignClientConfiguraion{
@Bean
public Client feignClient(CachingSpringLoadBalancerFactory factory,
SpringClientFactory clientFactory,
okhttp3.OkHttpClient okHttpClient){
OkHttpClient delegate = new OkHttpClient(okHttpClient);
MyFeignClient myFeignClint = new MyFeignClient(delegate);
return new LoadBalancerFeignClient(myFeignClint, cachingFactory, clientFactory);
}
// 其它,略
}
但这样一来,势必要给默认的Client、Apache的Client……等各种Client都要重写一套配置。不仅麻烦,而且容易出现疏漏。
另一种方法是在MyFeignClientProcessor中,替换掉LoadBalancerFeignClient的delegate属性。麻烦之处在于,LoadBalancerFeignClient类只提供了getDelegate()方法,没有提供setDelegate(Client)方法。如果要替换这个字段,必须借助反射:
public class MyFeignClientProcessor implements BeanPostProcessor{
@Override
public Object postProcessAfterInitialization(Object bean, String beanName){
if(bean instanceof LoadBalancerFeignClient){
LoadBalancerFeignClient lbClient = (LoadBalancerFeignClient)bean;
MyFeignClient myFeignClient = new MyFeignClient(lbClient.getDelegate());
// 使用反射,将lbClient.delegate赋值为myFeignClient。这里略
return lbClient;
}else if(bean instanceof Client){
return new MyFeignClient((Client)bean);
}else{
return bean;
}
}
}
这样,无论LoadBalancerFeignClient中的delegate是什么类,都可以轻松地转换为MyFeignClient。最大的问题在于这里使用了反射。个人不太喜欢反射……于是就有了第三个方案。
方案三:提供LoadBalancerFeignClient子类
第三个方案其实有点头痛医头、脚痛医脚。既然框架里必须判断 "client instanceof LoadBalancerFeignClient",那我提供一个LoadBalancerFeignClient的子类不就行了嘛:
public class MyLoadBalancerFeignClient extends LoadBalancerFeignClient{
public MyLoadBalancerFeignClient(Client delegate,
CachingSpringLoadBalancerFactory factory,
SpringClientFactory clientFactory){
super(delegate, factory, clientFactory);
}
@Override
public Response execute(Request reuqest, Options options)throws IOException{
// 处理业务逻辑,略
//然后调用父类逻辑处理
return super.execute(request, options);
}
}
/** 对应的BeanPostProcessor也要做点修改 */
public class MyFeignClientProcessor implements BeanPostProcessor{
@Autowired
private CachingSpringLoadBalancerFactory factory,
@Autowired
private SpringClientFactory clientFactory
@Override
public Object postProcessAfterInitialization(Object bean, String beanName){
if(bean instanceof LoanBalancerFeignClient){
// 新增针对性的处理
LoanBalancerFeignClient lbClient = (LoanBalancerFeignClient)bean;
Client delegate = lbClient.getDelegate();
return new MyLoadBalancerFeignClient(delegate, factory, clientFactory);
}else if(bean instanceof Client){
// 保留原逻辑
return new MyFeignClient((Client)bean);
}else{
return bean;
}
}
}
这个方案也确实可行。与反射方案孰优孰劣,各位可以自己判断。
回到方案一:如果是我,会怎么做?
解决具体问题之后,我忍不住想入非非了一下。这个FeignClientFactoryBean,如果一开始就由我来写,为了让这个instanceof和代理兼容,我会怎么做呢?
我首先想到的是在Client接口上加一个方法,用以标记该对象是否支持负载均衡。这样,原有的instanceof判断就可以改为用这个方法来判断了:
public interface Client{
/** 已有方法 */
Response execute(Request request, Options options)throws IOException;
/** 新增方法,判断是否支持本地负载均衡 */
default boolean isLoadBalancable(){return false;}
}
看起来不错,实际上不太合适。Client接口的调用方并不关心这个对象是否支持负载均衡——最多在装配时关心,而在运行期不关心。在这个接口上增加方法,等于把不必要的底层细节暴露了出去。此外,如果加了这个方法,势必还要在Client接口上再加个getDelegate()方法。这就更糟糕了。
更进一步说,因为instanceof和代理不能共存,就打算把instanceof彻底踢出局,这种想法未免太二极管了一些。我并不需要二选一,如果能调和矛盾、兼容并蓄,那也是皆大欢喜。
比如,既然isLoadBalancable()方法不适合放到Client接口内,那可以考虑把它放在一个新的接口里:
public interface LoadBalancable{
Client removeLoanBalance();
}
这样,FeignClientFactoryBean就可以这样实现:
public class FeignClientFactoryBean{
<T> T getTarget() {
FeignContext context = this.applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
if (!StringUtils.hasText(this.url)) {
if (!this.name.startsWith("http")) {
this.url = "http://" + this.name;
}
else {
this.url = this.name;
}
this.url += cleanPath();
return (T) loadBalance(builder, context,
new HardCodedTarget<>(this.type, this.name, this.url));
}
if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
this.url = "http://" + this.url;
}
String url = this.url + cleanPath();
Client client = getOptional(context, Client.class);
if (client != null) {
// 改动在这里
if (client instanceof LoadBalancable) {
// not load balancing because we have a url,
// but ribbon is on the classpath, so unwrap
client = ((LoadBalancable) client).removeLoanBalance();
}
builder.client(client);
}
Targeter targeter = get(context, Targeter.class);
return (T) targeter.target(this, builder, context,
new HardCodedTarget<>(this.type, this.name, url));
}
// 其它代码略
}
虽然也用到了instanceof,但只要我们的代理这样写,就可以与之兼容:
public class MyFeignClient implements Client, LoadBalancable{
private final Client delegate;
public MyFeignClient(Client delegate){
this.delegate = delegate;
}
@Override
public Response execute(Request request, Request.Options options) throws IOException{
// 做一些业务逻辑,然后用delgate做正常的处理
return delegate.execute(request, options);
}
/**
* 新的方法在这里。当前代理是否支持负载均衡、是否移除负载均衡能力,都可以委托给delegate来处理。
* 当然,如果delegate不支持负载均衡,那当前类也不行。直接返回自己即可。
*/
public Client removeLoanBalance(){
if(delegate instanceof LoadBalancable){
return ((LoadBalancable) delegate).removeLoanBalance();
} else{
return this;
}
}
}
写到这里发现,其实深层问题并不是insanceof与代理水火不容,而是原生的LoanBalancerFeignClient类难以扩展。如果像我上面这样给它定义一个接口,或者在OkHttpFeignLoadBalancedConfiguraion中使用更灵活的依赖注入方式,我都不用这样大费周章的搞这么些方案。
嗯,定义一个接口,使用更灵活的依赖注入方式,这其实都是依赖倒置原则的要求……不过这是另外的话题,就此打住吧。