我曾遇到过这么一个案例:使用 Spring Cloud 做微服务调用,为方便统一处理 Feign,想到了用 AOP 实现,即使用 within 指示器匹配 feign.Client 接口的实现进行 AOP 切入。代码如下,通过 @Before 注解在执行方法前打印日志,并在代码中定义了一个标记了@FeignClient 注解的 Client 类,让其成为一个 Feign 接口:
package org.geekbang.time.commonmistakes.springpart2.aopfeign.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "client")
public interface Client {
@GetMapping("/feignaop/server")
String api();
}
package org.geekbang.time.commonmistakes.springpart2.aopfeign;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableFeignClients(basePackages = "org.geekbang.time.commonmistakes.springpart2.aopfeign.feign")
public class Config {
}
package org.geekbang.time.commonmistakes.springpart2.aopfeign;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Slf4j
@Component
public class WrongAspect {
@Before("within(feign.Client+)")
public void before(JoinPoint pjp) {
log.info("within(feign.Client+) pjp {}, args:{}", pjp, pjp.getArgs());
}
}
通过 Feign 调用服务后可以看到日志中有输出,的确实现了 feign.Client 的切入,切入的是 execute 方法:
[15:48:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspec
Binary data, feign.Request$Options@5c16561a]
一开始这个项目使用的是客户端的负载均衡,也就是让 Ribbon 来做负载均衡,代码没啥问题。后来因为后端服务通过 Nginx 实现服务端负载均衡,所以开发同学把@FeignClient 的配置设置了 URL 属性,直接通过一个固定 URL 调用后端服务:
package org.geekbang.time.commonmistakes.springpart2.aopfeign.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "anotherClient", url = "http://localhost:45678")
public interface ClientWithUrl {
@GetMapping("/feignaop/server")
String api();
}
但这样配置后,之前的 AOP 切面竟然失效了,也就是 within(feign.Client+) 无法切入ClientWithUrl 的调用了。为了还原这个场景,我写了一段代码,定义两个方法分别通过 Client 和 ClientWithUrl 这两个 Feign 进行接口调用:
package org.geekbang.time.commonmistakes.springpart2.aopfeign;
import lombok.extern.slf4j.Slf4j;
import org.geekbang.time.commonmistakes.springpart2.aopfeign.feign.Client;
import org.geekbang.time.commonmistakes.springpart2.aopfeign.feign.ClientWithUrl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RequestMapping("feignaop")
@RestController
public class FeignAopConntroller {
@Autowired
private Client client;
@Autowired
private ClientWithUrl clientWithUrl;
@Autowired
private ApplicationContext applicationContext;
@GetMapping("client")
public String client() {
return client.api();
}
@GetMapping("clientWithUrl")
public String clientWithUrl() {
return clientWithUrl.api();
}
@GetMapping("server")
public String server() {
return "OK";
}
}
可以看到,调用 Client 后 AOP 有日志输出,调用 ClientWithUrl 后却没有:
[15:50:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspec
Binary data, feign.Request$Options@5c16561
这就很费解了。难道为 Feign 指定了 URL,其实现就不是 feign.Clinet 了吗?要明白原因,我们需要分析一下 FeignClient 的创建过程,也就是分析FeignClientFactoryBean 类的 getTarget 方法。源码第 4 行有一个 if 判断,当 URL 没有内容也就是为空或者不配置时调用 loadBalance 方法,在其内部通过 FeignContext 从容器获取 feign.Client 的实例:
T getTarget() {
FeignContext context = this.applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
if (!StringUtils.hasText(this.url)) {
...
return (T) loadBalance(builder, context,
new HardCodedTarget<>(this.type, this.name, this.url));
}.
..
String url = this.url + cleanPath();
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);
}.
..
}protected T loadBalance(Feign.Builder builder, FeignContext context,
HardCodedTarget target) {
Client client = getOptional(context, Client.class);
if (client != null) {
builder.client(client);
Targeter targeter = get(context, Targeter.class);
return targeter.target(this, builder, context, target);
}
...
}
protected T getOptional(FeignContext context, Class type) {
return context.getInstance(this.contextId, type);
}
调试一下可以看到,client 是 LoadBalanceFeignClient,已经是经过代理增强的,明显是一个 Bean:
所以,没有指定 URL 的 @FeignClient 对应的 LoadBalanceFeignClient,是可以通过feign.Client 切入的。在我们上面贴出来的源码的 16 行可以看到,当 URL 不为空的时候,client 设置为了LoadBalanceFeignClient 的 delegate 属性。其原因注释中有提到,因为有了 URL 就不需要客户端负载均衡了,但因为 Ribbon 在 classpath 中,所以需要从LoadBalanceFeignClient 提取出真正的 Client。断点调试下可以看到,这时 client 是一个ApacheHttpClient
那么,这个 ApacheHttpClient 是从哪里来的呢?这里,我教你一个小技巧:如果你希望知道一个类是怎样调用栈初始化的,可以在构造方法中设置一个断点进行调试。这样,你就可以在 IDE 的栈窗口看到整个方法调用栈,然后点击每一个栈帧看到整个过程。
用这种方式,我们可以看到,是 HttpClientFeignLoadBalancedConfiguration 类实例化的 ApacheHttpClient:
进一步查看 HttpClientFeignLoadBalancedConfiguration 的源码可以发现,LoadBalancerFeignClient 这个 Bean 在实例化的时候,new 出来一个ApacheHttpClient 作为 delegate 放到了 LoadBalancerFeignClient 中:
@Bean
@ConditionalOnMissingBean(Client.class)
public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory,
SpringClientFactory clientFactory, HttpClient httpClient) {
ApacheHttpClient delegate = new ApacheHttpClient(httpClient);
return new LoadBalancerFeignClient(delegate, cachingFactory, clientFactory)
}
public LoadBalancerFeignClient(Client delegate,
CachingSpringLoadBalancerFactory lbClientFactory,
SpringClientFactory clientFactory) {
this.delegate = delegate;
this.lbClientFactory = lbClientFactory;
this.clientFactory = clientFactory;
}
显然,ApacheHttpClient 是 new 出来的,并不是 Bean,而 LoadBalancerFeignClient是一个 Bean。有了这个信息,我们再来捋一下,为什么 within(feign.Client+) 无法切入设置过 URL 的@FeignClient ClientWithUrl:因此,定义了 URL 的 FeignClient 采用 within(feign.Client+) 无法切入。那,如何解决这个问题呢?有一位同学提出,修改一下切点表达式,通过 @FeignClient 注解来切:
package org.geekbang.time.commonmistakes.springpart2.aopfeign;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@Aspect
@Slf4j
//@Component
public class Wrong2Aspect {
@Before("@within(org.springframework.cloud.openfeign.FeignClient)")
public void before(JoinPoint pjp) {
log.info("@within(org.springframework.cloud.openfeign.FeignClient) pjp {}, args:{}", pjp, pjp.getArgs());
}
}
修改后通过日志看到,AOP 的确切成功了:
[15:53:39.093] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspe
但仔细一看就会发现,这次切入的是 ClientWithUrl 接口的 API 方法,并不是client.Feign 接口的 execute 方法,显然不符合预期。
这位同学犯的错误是,没有弄清楚真正希望切的是什么对象。@FeignClient 注解标记在Feign Client 接口上,所以切的是 Feign 定义的接口,也就是每一个实际的 API 接口。而通过 feign.Client 接口切的是客户端实现类,切到的是通用的、执行所有 Feign 调用的execute 方法。那么问题来了,ApacheHttpClient 不是 Bean 无法切入,切 Feign 接口本身又不符合要求。怎么办呢?
经过一番研究发现,ApacheHttpClient 其实有机会独立成为 Bean。查看HttpClientFeignConfiguration 的源码可以发现,当没有 ILoadBalancer 类型的时候,自动装配会把 ApacheHttpClient 设置为 Bean。
这么做的原因很明确,如果我们不希望做客户端负载均衡的话,应该不会引用 Ribbon 组件的依赖,自然没有 LoadBalancerFeignClient,只有 ApacheHttpClient:
@Configuration
@ConditionalOnClass(ApacheHttpClient.class)
@ConditionalOnMissingClass("com.netflix.loadbalancer.ILoadBalancer")
@ConditionalOnMissingBean(CloseableHttpClient.class)
@ConditionalOnProperty(value = "feign.httpclient.enabled", matchIfMissing = tru
protected static class HttpClientFeignConfiguration {
@Bean
@ConditionalOnMissingBean(Client.class)
public Client feignClient(HttpClient httpClient) {
return new ApacheHttpClient(httpClient);
}
}
那,把 pom.xml 中的 ribbon 模块注释之后,是不是可以解决问题呢?
org.springframework.cloud
spring-cloud-starter-netflix-ribbon
但,问题并没解决,启动出错误了:
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class feig
at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:657)
at org.springframework.cglib.core.DefaultGeneratorStrategy.generate(DefaultGe
这里,又涉及了 Spring 实现动态代理的两种方式:Spring Boot 2.x 默认使用 CGLIB 的方式,但通过继承实现代理有个问题是,无法继承final 的类。因为,ApacheHttpClient 类就是定义为了 final
public final class ApacheHttpClient implements Client {
为解决这个问题,我们把配置参数 proxy-target-class 的值修改为 false,以切换到使用
JDK 动态代理的方式:
spring.aop.proxy-target-class=false
修改后执行 clientWithUrl 接口可以看到,通过 within(feign.Client+) 方式可以切入
feign.Client 子类了。以下日志显示了 @within 和 within 的两次切入:
[16:29:55.303] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.Wrong2Aspe
[16:29:55.310] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspec
Binary data, feign.Request$Options@387550b0]
这下我们就明白了,Spring Cloud 使用了自动装配来根据依赖装配组件,组件是否成为Bean 决定了 AOP 是否可以切入,在尝试通过 AOP 切入 Spring Bean 的时候要注意加上上一讲的两个案例,我就把 IoC 和 AOP 相关的坑点和你说清楚了。除此之外,我们在业务开发时,还有一个绕不开的点是,Spring 程序的配置问题。接下来,我们就具体看看吧。