观 Spring Cloud OpenFeign 设计有感


Spring Cloud FeignSpring 官方提供的一个轻量级 web 声明式客户端,它的设计初衷在于使我们更容易地编写 web 客户端程序,它将一个 web 请求所需要的信息抽象为基于接口和注解的元信息,使我们从复杂的编码中解放出来,而真实的请求交互过程依赖于 Feign 底层的代理模式进行处理。同时 Feign 提供了强大的扩展性,例如我们可以自定义 ContractEncoderDecoder 等组件,个性化请求数据的编解码过程,也可以选用不同的 HTTP 客户端解决方案作为 Feign 底层的支撑。甚至我们可以将自定义的网络交互协议集成到 Feign 之中,Feign 本身更像是一种代理框架,几乎不决定性能表现,它的性能表现取决于底层使用的网络交互技术,偶然看到网上有人将 Feign 与诸如 Dubbo 这样的 RPC 框架进行性能对比,本质上这是两种定位的技术,理论上 Feign 可以将任何网络交互方案集成进来,两者并不是对立或排斥的。

FeignClient 自动装配


@FeignClient(name = "user")
public interface UserClient {

    @GetMapping("getUserInfo")
    UserInfo getUserInfo(@RequestParam("id") long id);

    @PostMapping("addUserInfo")
    long addUserInfo(@RequestBody UserInfo userInfo);

}

FeignClient 是基于接口形式定义的,那么我们自定义的 FeignClient 是如何被装配到 IOC 容器中的呢?首先我们需要引入注解 EnableFeignClients 并指定 basePackages告诉 Spring 容器到目标 package 下扫描我们定义的所有 FeignClient 并将其装配为 Spring Bean,其后我们才可以通过 DI 依赖注入的方式使用代理对象进行网络交互。

class FeignClientsRegistrar
		implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
// ...
	@Override
	public void registerBeanDefinitions(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
		registerDefaultConfiguration(metadata, registry);
		registerFeignClients(metadata, registry);
	}
// ...
}

FeignClientsRegistrar 我们可以看到 FeignClient 被装配的核心逻辑,首先被装配的是 Feign 相关的默认配置,跟踪代码我们可以知道 Feign 配置最后会被注册一个 FeignClientSpecification 类型的 Bean,这个 Bean 持有了 FeignClient 所有的配置信息。

class FeignClientsRegistrar
		implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
	public void registerFeignClients(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
		// ...
		for (String basePackage : basePackages) {
			Set<BeanDefinition> candidateComponents = scanner
					.findCandidateComponents(basePackage);
			for (BeanDefinition candidateComponent : candidateComponents) {
				if (candidateComponent instanceof AnnotatedBeanDefinition) {
					// 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 = getClientName(attributes);
					registerClientConfiguration(registry, name,
							attributes.get("configuration"));

					registerFeignClient(registry, annotationMetadata, attributes);
				}
			}
		}
		// ...
	}
}

除了 Feign 的全局配置以外,我们也可以针对单个 FeignClient 进行个性化配置,设置其 contextId 用于配置隔离,同样的,个性化配置最终也需要注册为 FeignClientSpecification 类型的 Bean,被 IOC 容器管理起来,以待后续使用。另外最核心的就是通过 registerFeignClient 方法构建 FeignClient 代理对象,在这个例子中也就是构建 UserClient 的代理对象。跟踪代码可以发现代理对象的创建流程是通过 FeignClientFactoryBean 进行控制的。跟踪 FeignClientFactoryBean#getTarget 方法,我们可以大致归纳其创建过程。

class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
	// ...
	<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);
		// ...
		Targeter targeter = get(context, Targeter.class);
		return (T) targeter.target(this, builder, context,
				new HardCodedTarget<>(this.type, this.name, url));
	}
}
  • Spring 上下文中获取 FeignContext 对象,FeignContext 中持有了全局以及所有 FeignClientConfiguration,它是整个应用的 Feign 配置上下文。
  • 通过 Feign.Builder 构建者模式进行配置填充,这里面要注意各层级配置的优先级。
  • 构建请求地址,若没有采取硬编码的方式指定 url,则尝试使用负载均衡的方式构建客户端,一般需要依赖于 Ribbon 这样的组件,此处暂且不表。
  • 调用 Feign#build 创建目前类型(此处为 UserClient)的代理对象。
public class ReflectiveFeign extends Feign {
	@Override
  public <T> T newInstance(Target<T> target) {
    Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
    Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
    List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();

    for (Method method : target.type().getMethods()) {
      if (method.getDeclaringClass() == Object.class) {
        continue;
      } else if (Util.isDefault(method)) {
        DefaultMethodHandler handler = new DefaultMethodHandler(method);
        defaultMethodHandlers.add(handler);
        methodToHandler.put(method, handler);
      } else {
        methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method)));
      }
    }
    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
        new Class<?>[] {target.type()}, handler);

    for (DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) {
      defaultMethodHandler.bindTo(proxy);
    }
    return proxy;
  }
}

可以看到 Feign 底层是基于 JDK 动态代理创建针对目标接口的代理对象,这里面主要涉及到几个关键流程:

  • 根据 Contract 规则解析 FeignClient 中的各个 method 获取方法元信息。之所以我们可以在 FeignClient 中使用 Spring MVC 的注解,因为 SpringMvcContract 可以成功识别这些注解将之转化为有意义的元信息,由于广大开发者对于 Spring MVC 比较熟悉,因此 Spring 提供了这样的 Contract 帮助我们降低学习成本。
  • 构建一个 Map<Method, MethodHandler>,其中维护了目标方法到方法执行器的映射关系,当我们调用目标方法时,最终请求会被委托给对应的 MethodHandler 执行。
  • 此时代理对象创建完成,FeignInvocationHandlerinvoke 逻辑被成功嵌入到了我们的代理对象中。

FeignClient 执行流程


FeignClient执行流程

Encoder 编码器

@Configuration(proxyBeanMethods = false)
public class FeignClientsConfiguration {
	// ...
	@Bean
	@ConditionalOnMissingBean
	@ConditionalOnMissingClass("org.springframework.data.domain.Pageable")
	public Encoder feignEncoder() {
		return new SpringEncoder(this.messageConverters);
	}
	// ...
}

Encoder 是对于我们业务上 JavaBean 转化为底层请求信息这一过程的抽象,默认的实现是 SpringEncoder ,主要是依赖于 Spring 底层的 HttpMessageConverter 进行实现。这个过程的主要目的是将我们的 JavaBean 转化为可进行网络传输的字节流。以 UserClient#addUserInfo 这个接口为例,我们传入的 JavaBean 类型为 UserInfo.class,这时候 SpringEncoder 会帮助我们匹配到 MappingJackson2HttpMessageConverter 转换器,最终将 UserInfo 对象转换为字节流暂存到 RequestTemplate 对象中进行 Feign 后续流程。

RequestInterceptor 拦截器

public interface RequestInterceptor {

  /**
   * Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
   */
  void apply(RequestTemplate template);
}

如果我们想在 Encoder 编码阶段之后对 RequestTemplate 做进一步的修改,RequestInterceptor 为我们提供了这样的可能。同样 RequestInterceptor 也支持不同的作用域,可以作用于全局,也可以只作用于单个 FeignClient ,我们只需要将自定义的 RequestInterceptor 注册为 Spring Bean 即可生效。

Client 请求客户端

在本文的引言中我们提到过 Feign 本身不决定服务间交互的性能表现,它的的性能主要取决于底层依赖的请求客户端,也就是 Client 所代表的网络请求发送的抽象过程。在这一点上 Feign 为我们提供了足够的扩展空间,理论上整个 Client 请求过程我们都可以通过自定义编码完成。Client 的默认实现是基于 JDK 中的 HttpURLConnection ,这是一种较为简洁的兜底解决方案,在性能以及资源利用上表现不佳,在实际生产场景中我们可以考虑使用优秀的开源客户端对其进行替代,例如 Apache HttpClientOkHttp 等。

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-okhttp</artifactId>
</dependency>
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Feign.class)
@EnableConfigurationProperties({ FeignClientProperties.class,
		FeignHttpClientProperties.class })
@Import(DefaultGzipDecoderConfiguration.class)
public class FeignAutoConfiguration {
	// ...
	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(OkHttpClient.class)
	@ConditionalOnMissingClass("com.netflix.loadbalancer.ILoadBalancer")
	@ConditionalOnMissingBean(okhttp3.OkHttpClient.class)
	@ConditionalOnProperty("feign.okhttp.enabled")
	protected static class OkHttpFeignConfiguration {
		// ...
		@Bean
		@ConditionalOnMissingBean(Client.class)
		public Client feignClient(okhttp3.OkHttpClient client) {
			return new OkHttpClient(client);
		}
		// ...
	}
	// ...
}

以集成 OkHttp 为例,当我们项目的 classpath 下存在 OkHttp 依赖时,FeignAutoConfiguration 中的条件化装配会自动为我们装配 OkHttpClient 客户端,当使用 FeignClientFactoryBean 去初始化我们的 FeignClient 时,自然能够从上下文中获取到 OkHttpClient 实例作用到 FeignClient 的构建过程中,这个 Client 切换过程对于上层的调用是无感知的,这完全得益于 Feign 的抽象能力,使我们从复杂的底层网络请求中解放出来。

Decoder 解码器

当客户端受到服务端响应的 response 对象,我们需要把它转化为有意义的 JavaBean,也就是我们目标方法的 returnTypeDecoder 的默认实现为 OptionalDecoder,它是 SpringDecoder 的一种包装,此处主要是为了兼容 Java8 引入的 Optional 类型,最终底层也是基于 HttpMessageConverter 的类型转换,此处不再赘述。

Feign 负载均衡


Feign 默认采用 Ribbon 作为负载均衡实现,当我们的项目 classpath 下存在 spring-cloud-starter-netflix-ribbon 依赖时,配置类 FeignRibbonClientAutoConfiguration 会被自动装配。

@ConditionalOnClass({ ILoadBalancer.class, Feign.class })
@ConditionalOnProperty(value = "spring.cloud.loadbalancer.ribbon.enabled",
		matchIfMissing = true)
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore(FeignAutoConfiguration.class)
@EnableConfigurationProperties({ FeignHttpClientProperties.class })
// Order is important here, last should be the default, first should be optional
// see
// https://github.com/spring-cloud/spring-cloud-netflix/issues/2086#issuecomment-316281653
@Import({ HttpClientFeignLoadBalancedConfiguration.class,
		OkHttpFeignLoadBalancedConfiguration.class,
		DefaultFeignLoadBalancedConfiguration.class })
public class FeignRibbonClientAutoConfiguration {
	// ...
}

我们可以看到有三个配置类被依次引入,分别是 DefaultFeignLoadBalancedConfigurationOkHttpFeignLoadBalancedConfigurationHttpClientFeignLoadBalancedConfiguration,分别对应 Feign 支持的三种 HTTP 实现:HttpUrlConnectionOkHttp 以及 Apache HttpClient,这里有一个典型的 Decorator Pattern (装饰器模式)使用场景,试想一下 Client 的实现可以是不同的,如果分别对每一种客户端类型进行负载均衡的定制化处理,那么代码复杂度是可想而知,如果负载均衡的逻辑是统一的,那么我们完全可以把这部分逻辑抽象为一个装饰器,用它去增强不同类型的 Client,而被装饰后的 Client 依赖只是一个 Client 对象,那么对于调用方来说这一层装饰是无感知的,这无疑是一种良好的选择。

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(OkHttpClient.class)
@ConditionalOnProperty("feign.okhttp.enabled")
@Import(OkHttpFeignConfiguration.class)
class OkHttpFeignLoadBalancedConfiguration {

	@Bean
	@ConditionalOnMissingBean(Client.class)
	public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory,
			SpringClientFactory clientFactory, okhttp3.OkHttpClient okHttpClient) {
		OkHttpClient delegate = new OkHttpClient(okHttpClient);
		return new LoadBalancerFeignClient(delegate, cachingFactory, clientFactory);
	}

}

OkHttp 为例,LoadBalancerFeignClient 作为负载均衡装饰器,它本身是 Client 的实现,用 LoadBalancerFeignClient 装饰 OkHttpClient 后,请求首先会被 LoadBalancerFeignClient 处理,嵌入负载均衡逻辑后,负载均衡客户端会自动帮助我们选取服务实例,再将请求委托给真正的 OkHttpClient 执行,之后的逻辑与常规请求无异,有一个注意点,引入负载均衡机制后 FeignClienturl 不能特殊指定,因为请求地址需要交给客户端自动选取,如果我们特殊指定那么也就失去了负载均衡的意义,这时候我们可以采用配置项 ${name}.ribbon.listOfServers 通过硬编码的方式指定某服务的实例列表,或者集成 DiscoveryClient 服务注册中心进行动态获取,此处暂且不表。

第三方服务注册中心如何适应 Feign 的负载均衡


上文我们谈到 Feign 的负载均衡是基于 Ribbon 实现的,所以负载均衡是 Ribbon 层面解决的问题,所以这个问题不妨说是第三方服务注册中心如何适应 Ribbon 的负载均衡,目前市面上有很多服务注册中心的解决方案,例如 EurekaZookeeperNacos 等,由于 Spring 生态强大的融合能力,这些不同的实现方案往往都会选择拥抱 Spring Cloud 定义的标准规范,Spring Cloud 更多地是去定义标准化接口,它并不强制开发者使用哪一种实现,而且随着 Spring Cloud 生态的蓬勃发展,这一指导思想能更为明显被我们感知到。

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties
// Order is important here, last should be the default, first should be optional
// see
// https://github.com/spring-cloud/spring-cloud-netflix/issues/2086#issuecomment-316281653
@Import({ HttpClientConfiguration.class, OkHttpRibbonConfiguration.class,
		RestClientRibbonConfiguration.class, HttpClientRibbonConfiguration.class })
public class RibbonClientConfiguration {
	// ...
	@Bean
	@ConditionalOnMissingBean
	@SuppressWarnings("unchecked")
	public ServerList<Server> ribbonServerList(IClientConfig config) {
		if (this.propertiesFactory.isSet(ServerList.class, name)) {
			return this.propertiesFactory.get(ServerList.class, config, name);
		}
		ConfigurationBasedServerList serverList = new ConfigurationBasedServerList();
		serverList.initWithNiwsConfig(config);
		return serverList;
	}
	// ...
}

对于负载均衡来说,当一个系统引入服务注册中心后,无非是服务实例列表的来源发生了改变,原来是采用硬编码方式配置的,现在需要从服务注册中心动态获取,在 Ribbon 的规范中 ServerList 接口代表服务实例列表的抽象,它的默认实现是 ConfigurationBasedServerList,顾名思义,默认是通过 ${name}.ribbon.listOfServers 配置源获取。毫无疑问,当我们引入服务注册中心,需要覆盖这一实现。

@Configuration
@ConditionalOnRibbonNacos
public class NacosRibbonClientConfiguration {

	@Bean
	@ConditionalOnMissingBean
	public ServerList<?> ribbonServerList(IClientConfig config,
			NacosDiscoveryProperties nacosDiscoveryProperties) {
		NacosServerList serverList = new NacosServerList(nacosDiscoveryProperties);
		serverList.initWithNiwsConfig(config);
		return serverList;
	}

	@Bean
	@ConditionalOnMissingBean
	public NacosServerIntrospector nacosServerIntrospector() {
		return new NacosServerIntrospector();
	}
}

此处以 Spring Cloud Alibaba Nacos 为例,当我们引入相关依赖后,NacosRibbonClientConfiguration 配置类被自动装配,NacosServerList 作为 Ribbon 规范中 ServerList 的实现,覆盖了原有的默认实现。将获取服务实例列表的请求委托给 Nacos 客户端,并定时从 Nacos 服务端获取服务列表维护到本地缓存中,这是一个典型的 Spring Cloud Alibaba 扩展 Spring Cloud 标准的范例,本文不再详细论述。

小结


Spring Cloud OpenFeign 的整体架构设计中我们还是可以得到很多启发,如何从一个复杂逻辑中抽象化标准流程,并提供关键的扩展点,这也是设计模式中所谓的开闭原则,对于抽象化的骨架,尽可能地去封闭,如果我们是一个框架的设计者,我们并不期待框架的使用者去修改我们所认定的骨架标准。同时我们应该在框架的关键节点提供丰富的扩展点,这是一个优秀框架灵活性和弹性的保障,引导使用者去实现这些扩展点以满足他们的个性化需求,当然扩展点的实现不是必须的,框架本身也应该提供兜底方案。这是一个愿景,如何规划好每一行代码去实现这个愿景,这是我们学习开源框架和阅读源码的根本目的。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 上一篇
Spring Cloud Circuit Breaker 服务容错 Spring Cloud Circuit Breaker 服务容错
参考 Spring Cloud 官方文档的描述,Spring Cloud Circuit Breaker 提供了不同断路器实现的抽象,它为开发者选用不同的断路器实现提供了标准化的 API,在实际的项目应用场景中,我们可以基于 Spring
2020-09-26 Ethan Zhang
下一篇 
锁之浅见 锁之浅见
在计算机科学中,锁是在执行多线程时用于强行限制资源访问的同步机制,即用于在并发控制中保证对互斥要求的满足。一般的锁是建议锁(advisory lock),每个线程在访问对应资源前都需获取锁的信息,再根据信息决定是否可以访问。若访问对应信息,
2020-08-17 Ethan Zhang