背景

因为一项需求,我需要为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
at com.netflix.loadbalancer.LoadBalancerContext.getServerFromLoadBalancer(LoadBalancerContext.java:483)
at com.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中使用更灵活的依赖注入方式,我都不用这样大费周章的搞这么些方案。

嗯,定义一个接口,使用更灵活的依赖注入方式,这其实都是依赖倒置原则的要求……不过这是另外的话题,就此打住吧。