Day644.Spring框架开发双刃剑 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day644.Spring框架开发双刃剑 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

Spring框架开发双刃剑

Hi,阿昌来也,今天学习记录的是针对Spring框架开发双刃剑的学习文章记录。

Spring 框架内部的复杂度主要表现为三点:

  • 第一,Spring 框架借助 IoC 和 AOP 的功能,实现了修改、拦截 Bean 的定义和实例的灵活性,因此真正执行的代码流程并不是串行的。
  • 第二,Spring Boot 根据当前依赖情况实现了自动配置,虽然省去了手动配置的麻烦,但也因此多了一些黑盒、提升了复杂度。
  • 第三,Spring Cloud 模块多版本也多,Spring Boot 1.x 和 2.x 的区别也很大。如果要对 Spring Cloud 或 Spring Boot 进行二次开发的话,考虑兼容性的成本会很高。

一、Feign AOP 失效问题

一个案例

使用 Spring Cloud 做微服务调用,为方便统一处理 Feign,想到了用 AOP 实现,即使用 within 指示器匹配 feign.Client 接口的实现进行 AOP 切入。

代码如下,通过 @Before 注解在执行方法前打印日志,并在代码中定义了一个标记了 @FeignClient 注解的 Client 类,让其成为一个 Feign 接口:

//测试Feign
@FeignClient(name = "client")
public interface Client 
    @GetMapping("/feignaop/server")
    String api();


//AOP切入feign.Client的实现
@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
@Configuration
@EnableFeignClients(basePackages = "org.geekbang.time.commonmistakes.spring.demo4.feign")
public class Config 

通过 Feign 调用服务后可以看到日志中有输出,的确实现了 feign.Client 的切入,切入的是 execute 方法:

[15:48:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect        :20  ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1

Binary data, feign.Request$Options@5c16561a]

一开始这个项目使用的是客户端的负载均衡,也就是让 Ribbon 来做负载均衡,代码没啥问题。后来因为后端服务通过 nginx 实现服务端负载均衡,所以把 @FeignClient 的配置设置了 URL 属性,直接通过一个固定 URL 调用后端服务:

@FeignClient(name = "anotherClient",url = "http://localhost:45678")
public interface ClientWithUrl 
    @GetMapping("/feignaop/server")
    String api();

但这样配置后,之前的 AOP 切面竟然失效了,也就是 within(feign.Client+) 无法切入 ClientWithUrl 的调用了。

为了还原这个场景,我写了一段代码,定义两个方法分别通过 Client 和 ClientWithUrl 这两个 Feign 进行接口调用:

@Autowired
private Client client;

@Autowired
private ClientWithUrl clientWithUrl;

@GetMapping("client")
public String client() 
    return client.api();


@GetMapping("clientWithUrl")
public String clientWithUrl() 
    return clientWithUrl.api();

可以看到,调用 Client 后 AOP 有日志输出,调用 ClientWithUrl 后却没有:

[15:50:32.850] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect        :20  ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://client/feignaop/server HTTP/1.1

Binary data, feign.Request$Options@5c16561

这就很费解了。难道为 Feign 指定了 URL,其实现就不是 feign.Clinet 了吗?

要明白原因,我们需要分析一下 FeignClient 的创建过程,也就是分析 FeignClientFactoryBean 类的 getTarget 方法。源码第 4 行有一个 if 判断,当 URL 没有内容也就是为空或者不配置时调用 loadBalance 方法,在其内部通过 FeignContext 从容器获取 feign.Client 的实例:

<T> 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> T loadBalance(Feign.Builder builder, FeignContext context,
    HardCodedTarget<T> 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> T getOptional(FeignContext context, Class<T> 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:

  • 表达式声明的是切入 feign.Client 的实现类。
  • Spring 只能切入由自己管理的 Bean
  • 虽然 LoadBalancerFeignClient 和 ApacheHttpClient 都是 feign.Client 接口的实现,但是 HttpClientFeignLoadBalancedConfiguration 的自动配置只是把前者定义为 Bean,后者是 new 出来的、作为了 LoadBalancerFeignClient 的 delegate,不是 Bean
  • 在定义了 FeignClient 的 URL 属性后,我们获取的是 LoadBalancerFeignClient 的 delegate,它不是 Bean。

因此,定义了 URL 的 FeignClient 采用 within(feign.Client+) 无法切入。那,如何解决这个问题呢?有一位同学提出,修改一下切点表达式,通过 @FeignClient 注解来切:

@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.Wrong2Aspect       :17  ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]

但仔细一看就会发现,这次切入的是 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 = true)
protected static class HttpClientFeignConfiguration 
  @Bean
  @ConditionalOnMissingBean(Client.class)
  public Client feignClient(HttpClient httpClient) 
    return new ApacheHttpClient(httpClient);
  

那,把 pom.xml 中的 ribbon 模块注释之后,是不是可以解决问题呢?

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>

但,问题并没解决,启动出错误了:

Caused by: java.lang.IllegalArgumentException: Cannot subclass final class feign.httpclient.ApacheHttpClient
  at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:657)
  at org.springframework.cglib.core.DefaultGeneratorStrategy.generate(DefaultGeneratorStrategy.java:25)

这里,又涉及了 Spring 实现动态代理的两种方式

  • JDK 动态代理,通过反射实现,只支持对实现接口的类进行代理;
  • CGLIB 动态字节码注入方式,通过继承实现代理,没有这个限制。

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.Wrong2Aspect       :16  ] - @within(org.springframework.cloud.openfeign.FeignClient) pjp execution(String org.geekbang.time.commonmistakes.spring.demo4.feign.ClientWithUrl.api()), args:[]
[16:29:55.310] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.spring.demo4.WrongAspect        :15  ] - within(feign.Client+) pjp execution(Response feign.Client.execute(Request,Options)), args:[GET http://localhost:45678/feignaop/server HTTP/1.1


Binary data, feign.Request$Options@387550b0]

这下我们就明白了,Spring Cloud 使用了自动装配来根据依赖装配组件,组件是否成为 Bean 决定了 AOP 是否可以切入,在尝试通过 AOP 切入 Spring Bean 的时候要注意。加上上一讲的两个案例,我就把 IoC 和 AOP 相关的坑点和你说清楚了。


二、Spring 程序配置的优先级问题

我们知道,通过配置文件 application.properties,可以实现 Spring Boot 应用程序的参数配置。但我们可能不知道的是,Spring 程序配置是有优先级的,即当两个不同的配置源包含相同的配置项时,其中一个配置项很可能会被覆盖掉。

这,也是为什么我们会遇到些看似诡异的配置失效问题。我们来通过一个实际案例,研究下配置源以及配置源的优先级问题。对于 Spring Boot 应用程序,一般我们会通过设置 management.server.port 参数,来暴露独立的 actuator 管理端口。

这样做更安全,也更方便监控系统统一监控程序是否健康。

management.server.port=45679

有一天程序重新发布后,监控系统显示程序离线。但排查下来发现,程序是正常工作的,只是 actuator 管理端口的端口号被改了,不是配置文件中定义的 45679 了。

后来发现,运维同学在服务器上定义了两个环境变量 MANAGEMENT_SERVER_IP 和 MANAGEMENT_SERVER_PORT,目的是方便监控 Agent 把监控数据上报到统一的管理服务上:

MANAGEMENT_SERVER_IP=192.168.0.2
MANAGEMENT_SERVER_PORT=12345

问题就是出在这里。MANAGEMENT_SERVER_PORT 覆盖了配置文件中的 management.server.port,修改了应用程序本身的端口。

当然,监控系统也就无法通过老的管理端口访问到应用的 health 端口了。如下图所示,actuator 的端口号变成了 12345:

到这里坑还没完,为了方便用户登录,需要在页面上显示默认的管理员用户名,于是开发同学在配置文件中定义了一个 user.name 属性,并设置为 defaultadminname:

user.name=defaultadminname

后来发现,程序读取出来的用户名根本就不是配置文件中定义的。这,又是咋回事?

带着这个问题,以及之前环境变量覆盖配置文件配置的问题,我们写段代码看看,从 Spring 中到底能读取到几个 management.server.port 和 user.name 配置项。要想查询 Spring 中所有的配置,我们需要以环境 Environment 接口为入口。

接下来,我就与你说说 Spring 通过环境 Environment 抽象出的 Property 和 Profile:

  • 针对 Property,又抽象出各种 PropertySource 类代表配置源。一个环境下可能有多个配置源,每个配置源中有诸多配置项。在查询配置信息时,需要按照配置源优先级进行查询。
  • Profile 定义了场景的概念。通常,我们会定义类似 dev、test、stage 和 prod 等环境作为不同的 Profile,用于按照场景对 Bean 进行逻辑归属。同时,Profile 和配置文件也有关系,每个环境都有独立的配置文件,但我们只会激活某一个环境来生效特定环境的配置文件。


看看 Property 的查询过程。对于非 Web 应用,Spring 对于 Environment 接口的实现是 StandardEnvironment 类。

我们通过 Spring 注入 StandardEnvironment 后循环 getPropertySources 获得的 PropertySource,来查询所有的 PropertySource 中 key 是 user.name 或 management.server.port 的属性值;然后遍历 getPropertySources 方法,获得所有配置源并打印出来:

@Autowired
private StandardEnvironment env;
@PostConstruct
public void init()
    Arrays.asList("user.name", "management.server.port").forEach(key -> 
         env.getPropertySources().forEach(propertySource -> 
                    if (propertySource.containsProperty(key)) 
                        log.info(" ->  实际取值:", propertySource, propertySource.getProperty(key), env.getProperty(key));
                    
                );
    );

    System.out.println("配置优先级:");
    env.getPropertySources().stream().forEach(System.out::println);

输出的日志:

2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : ConfigurationPropertySourcesPropertySource name='configurationProperties' -> zhuye 实际取值:zhuye
2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : PropertiesPropertySource name='systemProperties' -> zhuye 实际取值:zhuye
2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : OriginTrackedMapPropertySource name='applicationConfig: [classpath:/application.properties]' -> defaultadminname 实际取值:zhuye
2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : ConfigurationPropertySourcesPropertySource name='configurationProperties' -> 12345 实际取值:12345
2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : OriginAwareSystemEnvironmentPropertySource name='' -> 12345 实际取值:12345
2020-01-15 16:08:34.054  INFO 40123 --- [           main] o.g.t.c.s.d.CommonMistakesApplication    : OriginTrackedMapPropertySource name='applicationConfig: [classpath:/application.properties]'Scrum需要一个双刃团队

Scrum需要一个双刃团队

Pycharm那些隐藏的实用小技巧

Pycharm那些隐藏的实用小技巧,yyds!

python开发学习-day16(Django框架初识)

Spring框架学习Day03(注解开发)