源码感悟之 Spring Cloud Ribbon


我们知道,Spring Cloud Ribbon 是 Spring Cloud 技术栈中用于微服务架构中服务间调用实现负载均衡功能的组件。在此实现中,Spring官方采用客户端负载均衡(Client Side Load Balancer)可谓有利有弊,一方面,客户端负载均衡由于相关配置由单个客户端维护,具有稳定性高,相互几乎无影响的优点,提高了负载均衡整体的可用性,同时,在升级相关依赖时,需要对各客户端进行单独升级,无疑提高了系统的维护成本。但无论如何,Spring Cloud Ribbon 对于负载均衡的实现与设计是值得我们感悟和思考的,其中的一些设计思想对我们设计自己的程序时有莫大的启发。

在不使用 Eureka 的情况下使用 Ribbon


(此处重点探讨Ribbon的实现机制,故尽量不引入其他依赖)

服务提供方接口 demo (http://localhost:8090/getPort) :
接口逻辑很简单,返回服务实例的端口号,用于区分实际访问了哪个实例

@RestController
public class ProviderController {

    @Value("${server.port}")
    private String port;

    @GetMapping("/getPort")
    public String getPort() {
        return port;
    }

}

服务消费方接口 demo (<http://localhost:8070/getPort):

服务引导类(添加 Ribbon 客户端注解)

@SpringBootApplication
@RibbonClient("provider")
public class ConsumerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ConsumerApplication.class, args);
    }
    
}

引入 spring-cloud-starter-netflix-ribbon 依赖

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

配置服务实例列表(如果集成 Eureka 会自动从注册中心获取,无需硬编码配置)

server:
  port: 8070

provider:
  ribbon:
    listOfServers: localhost:8090,localhost:8091,localhost:8092

装配 RestTemplate,并添加 @LoadBalanced 标示为需要客户端负载均衡

@Configuration
public class RestTemplateConfiguration {

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder.build();
    }

}

通过 RestTemplate 执行GET请求调用服务方接口获取服务实例的端口号并返回

@RestController
public class ConsumerController {

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("getPort")
    public String getPort() {
        return restTemplate.getForObject("http://provider/getPort", String.class);
    }

}

我们可以发现,当我们在消费方启动引导类上添加 @RibbonClient("provider") 注解后,使用 RestTemplate 访问接口 http://provider/getPort 时会自动对地址进行解析,而我们所做的只是在装配 RestTemplate 时增加了注解 @LoadBalanced ,实际此时 Spring 底层已经帮我们实现了负载均衡去请求 Provider 服务,我们可以猜测 Spring 底层是对 RestTemplate 做了文章,那么它究竟是怎么做的呢?

public interface RestTemplateCustomizer {

	void customize(RestTemplate restTemplate);

}

我们可以在 Spring 中找到 RestTemplateCustomizer 这个接口,这里可以理解成是一种较为简化的访问者模式,该接口的实现作为 RestTemplate 的 Visitor,通过相关 api 达到增强 RestTemplate 的目的,而 Ribbon 也是通过此种方式来嵌入负载均衡的逻辑。
spring-cloud-commons 依赖中我们一个配置类 LoadBalancerAutoConfiguration,通过该类的命名我们可以知道这是一个被 Spring Boot 应用自动装配的类(至于 Spring 为什么要将该类放在 commons 这样较为核心的包中,个人理解这也是一种面向接口编程的设计思想,如此 Spring Cloud 的核心包不需要强依赖于 Ribbon 这样的具体实现,提供更大的拓展性),在该类中我们可以看到这样一段代码:

@LoadBalanced 
@Autowired(required = false)
private List<RestTemplate> restTemplates = Collections.emptyList();

@Bean
public SmartInitializingSingleton loadBalancedRestTemplateInitializerDeprecated(
		final ObjectProvider<List<RestTemplateCustomizer>> restTemplateCustomizers) {
	return () -> restTemplateCustomizers.ifAvailable(customizers -> {
		for (RestTemplate restTemplate : LoadBalancerAutoConfiguration.this.restTemplates) {
			for (RestTemplateCustomizer customizer : customizers) {
				customizer.customize(restTemplate);
			}
		}
	});
}

可以看到在此配置类会获取上下文中所有被 @LoadBalanced(@LoadBalanced 是对注解 @Qualifier 的派生,可以理解成一种特定的限定符,用于标定某些特定的 bean)注解的 RestTemplate 实例,然后在 SmartInitializingSingleton(SmartInitializingSingleton中只有一个方法afterSingletonsInstantiated(),其作用是是 在spring容器管理的所有单例对象(非懒加载对象)初始化完成之后调用的回调接口。)生命周期回调中获取到所有的 RestTemplateCustomizer 实例对 RestTemplate 进行修饰,使之具有拓展功能。显然,Ribbon 必然也实现了RestTemplateCustomizer,在 LoadBalancerAutoConfiguration 可以看到如下代码:

@Configuration
@ConditionalOnMissingClass("org.springframework.retry.support.RetryTemplate")
static class LoadBalancerInterceptorConfig {

	@Bean
	public LoadBalancerInterceptor ribbonInterceptor(
			LoadBalancerClient loadBalancerClient,
			LoadBalancerRequestFactory requestFactory) {
		return new LoadBalancerInterceptor(loadBalancerClient, requestFactory);
	}

	@Bean
	@ConditionalOnMissingBean
	public RestTemplateCustomizer restTemplateCustomizer(
			final LoadBalancerInterceptor loadBalancerInterceptor) {
		return restTemplate -> {
			List<ClientHttpRequestInterceptor> list = new ArrayList<>(
					restTemplate.getInterceptors());
			list.add(loadBalancerInterceptor);
			restTemplate.setInterceptors(list);
		};
	}

}

可以看到 RestTemplate 中被添加了一个 LoadBalancerInterceptor 拦截器,并且这个拦截器的构造器中传入了 LoadBalancerClient 实例,而这个负载均衡客户端正是 Ribbon 所提供的,可以猜测是这个拦截器起了作用,我们可以进一步深入探究在 RestTemplate 被 Ribbon 修饰后,我们的请求到底是怎么被处理的。

打开 DEBUG 模式,可以看到我们装配的 RestTemplate 已经被 Ribbon 所修饰,持有了一个负载均衡拦截器。在处理请求的过程中,拦截器生效,执行 LoadBalancerInterceptor#intercept 方法,代码如下:

public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
		final ClientHttpRequestExecution execution) throws IOException {
	final URI originalUri = request.getURI();
	String serviceName = originalUri.getHost();
	Assert.state(serviceName != null,
			"Request URI does not contain a valid hostname: " + originalUri);
	return this.loadBalancer.execute(serviceName,
			this.requestFactory.createRequest(request, body, execution));
}

在该方法中实际调用负载均衡客户端 RibbonLoadBalancerClient#execute 方法执行请求。

自定义 Ribbon 客户端相关配置


RibbonClientConfiguration 中 Ribbon 为我们提供了默认配置,例如 IRule IPing ILoadBalancer 等核心 bean 都已经被自动装配到 Spring 容器中,当然,这些 bean 无疑都被 @ConditionalOnMissingBean 注解,意味着可以被我们自行配置的同类型 bean 所覆盖,通过自定义相关接口的实现便可以嵌入我们的自定义代码逻辑,这也是贯穿 Spring Boot 的一种设计,为开发人员提供可插拔式的配置。以 IPing 为例,默认的策略为 DummyPing ,代码如下:

public class DummyPing extends AbstractLoadBalancerPing {

    public DummyPing() {
    }

    public boolean isAlive(Server server) {
        return true;
    }

    @Override
    public void initWithNiwsConfig(IClientConfig clientConfig) {
    }
}

可以看到 isAlive 永远返回 true,也就意味着在不集成 Eureka(集成 Eureka 会采用服务健康检查机制,此处不详谈) 并不自定义策略的情况下 Ribbon 认为每个服务实例都是可用的,都有概率分配请求,这显然是一种比较粗暴的处理方式,我们可以借助 spring-boot-starter-actuator 提供的服务指标信息接口作为 Ribbon 进行 IPing 的依据。

@Slf4j
@Configuration
public class ConsumerRibbonConfiguration {

    @Bean
    public IPing iPing() {
        return new HealthCheckPing();
    }

    private static class HealthCheckPing implements IPing {

        private static final RestTemplate restTemplate = new RestTemplate();

        @Override
        public boolean isAlive(Server server) {
            String url = String.format("http://%s/actuator/health", server.getHostPort());
            try {
                Map<String, String> res = restTemplate.getForObject(url, Map.class);
                if (!CollectionUtils.isEmpty(res) && Status.UP.getCode().equals(res.get("status"))) {
                    return true;
                }
            } catch (Exception e) {
                log.error("server instance {} not online!", server, e);
            }
            return false;
        }

    }

}

如上 demo 所示,HealthCheckPing 被装配后,isAlive 方法会被定时调用探测目标服务健康状况,我们可以进一步探究它的实现机制。

public BaseLoadBalancer(String name, IRule rule, LoadBalancerStats stats,
          IPing ping, IPingStrategy pingStrategy) {

    logger.debug("LoadBalancer [{}]:  initialized", name);
    
    this.name = name;
    this.ping = ping;
    this.pingStrategy = pingStrategy;
    setRule(rule);
    setupPingTask(); // 设置定时任务
    lbStats = stats;
    init();
}

其核心逻辑位于 BaseLoadBalancer 的构造方法中,可以看到调用了 setupPingTask 方法。也就是说当 ILoadBalancer 被装配时,该定时任务已经被设置。但是经过测试发现,该定时任务并不会在程序启动阶段执行,而是在对该服务进行首次请求时才开始以一定时间间隔(可配置)执行,并且请求会被阻塞,等待此次检查完成。这就意味着 RibbonClientConfiguration 是被延迟加载的,并且每个客户端的配置是相互隔离的,主要通过 NamedContextFactory 的子容器特性实现,这里不再详细赘述。

客户端个性化配置


前面我们说过,Ribbon 是一种客户端负载均衡,虽然在一定程度上提高了程序的维护成本,但不可否认也带来了相当程度的灵活性,我们可以在客户端对任意一个服务提供方做定制化的配置,例如上文所说的自定义 IPing,我们可以让它的作用域仅限定于特定的客户端。

@Slf4j
@SpringBootApplication
@RibbonClient(name = "provider", configuration = { ConsumerApplication.HealthCheckPing.class })
public class ConsumerApplication {

    public static void main(String[] args) {
        SpringApplication.run(ConsumerApplication.class, args);
    }

    public static class HealthCheckPing implements IPing {

        private static final RestTemplate restTemplate = new RestTemplate();

        @Override
        public boolean isAlive(Server server) {
            String url = String.format("http://%s/actuator/health", server.getHostPort());
            try {
                Map<String, String> res = restTemplate.getForObject(url, Map.class);
                if (!CollectionUtils.isEmpty(res) && Status.UP.getCode().equals(res.get("status"))) {
                    return true;
                }
            } catch (Exception e) {
                log.error("server instance {} not online!", server, e);
            }
            return false;
        }

    }

}

如上代码所示,我们可以在 @RibbonClientconfiguration 属性中注入相应的 Class 类型指定相关配置,这种方式具有很强的灵活性。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 上一篇
事件驱动之 Spring Cloud Bus 事件驱动之 Spring Cloud Bus
在分布式系统中我们往往需要大量的状态广播,以通知系统中各个节点,触发相关行为,例如用于保证分布式数据一致性或进行相关补偿行为。这时候事件驱动型的编程模型有利于我们处理相关问题,在一个节点发布一个事件,另外N个节点监听该事件作出相关反应,这
2019-09-25 Ethan Zhang
本篇 
源码感悟之 Spring Cloud Ribbon 源码感悟之 Spring Cloud Ribbon
我们知道,Spring Cloud Ribbon 是 Spring Cloud 技术栈中用于微服务架构中服务间调用实现负载均衡功能的组件。在此实现中,Spring官方采用客户端负载均衡(Client Side Load Balancer)
2019-09-20 Ethan Zhang