Spring 技巧:Spring Cloud Loadbalancer

工程 | Josh Long | 2020年3月25日 | ...

演讲者: Josh Long (@starbuxman)

大家好,Spring 粉丝们!欢迎来到另一期 Spring 技巧!在本期中,我们将了解 Spring Cloud 中的一项新功能,Spring Cloud Loadbalancer。Spring Cloud Loadbalancer 是一个通用抽象,可以完成我们过去使用 Netflix 的 Ribbon 项目所做的工作。Spring Cloud 仍然支持 Netflix Ribbon,但 Netflix Ribbon 的日子已经不多了,就像 Netflix 微服务堆栈中的许多其他组件一样,因此我们提供了一个抽象来支持替代方案。

服务注册中心

为了使用 Spring Cloud Load Balancer,我们需要启动并运行一个服务注册中心。服务注册中心使得以编程方式查询系统中给定服务的位置变得非常简单。有几种流行的实现,包括 Apache Zookeeper、Netflix 的 Eureka、Hashicorp Consul 等等。你甚至可以将 Kubernetes 和 Cloud Foundry 用作服务注册中心。Spring Cloud 提供了一个抽象 `DiscoveryClient`,你可以使用它与这些服务注册中心进行通用通信。服务注册中心启用了许多用传统的 DNS 无法实现的模式。我喜欢做的一件事是客户端负载均衡。客户端负载均衡要求客户端代码决定哪个节点接收请求。系统中存在任意数量的服务实例,每个客户端都可以决定它们是否适合处理特定请求。如果能在发起请求(否则可能会失败)之前做出决定,那就更好了。这节省了时间,减轻了服务对繁琐的流量控制需求,并使我们的系统更加动态,因为我们可以查询其拓扑结构。

你可以运行任何你喜欢 Service Registry。我喜欢使用 Netflix Eureka 来处理这类事情,因为它更容易设置。我们来设置一个新的实例。如果你愿意,可以下载并运行一个标准镜像,但我想使用 Spring Cloud 提供的一部分预配置实例。

前往 Spring Initializer,选择 `Eureka Server` 和 `Lombok`。我将我的项目命名为 `eureka-service`。点击 `Generate`。

使用内置 Eureka Service 的大部分工作都在配置中,我在这里重印了配置。

server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

然后你需要自定义 Java 类。在你的类上添加 `@EnableEurekaServer` 注解。

package com.example.eurekaservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class EurekaServiceApplication {

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

}

你现在可以运行它了。它将在端口 `8761` 上可用,其他客户端默认会连接到该端口。

简单的 API

现在我们来看看 API。我们的 API 再简单不过了。我们只需要一个客户端可以发出请求的端点。

前往 Spring Initializr,生成一个包含 `Reactive Web`、`Lombok` 和 `Eureka Discovery Client` 的新项目。最后一点是关键!你在接下来的 Java 代码中不会看到它被使用。它是 完全的自动配置,这我们在 2016 年就介绍过了,在应用程序启动时运行。自动配置会使用 `spring.application.name` 属性自动将应用程序注册到指定的注册中心(在本例中,我们使用的是 Netflix Eureka 的 `DiscoveryClient` 实现)。

指定以下属性。

spring.application.name=api
server.port=9000

我们的 HTTP 端点是一个“Hello, world!”处理器,它使用了我们在 早在 2017 年的另一个 Spring Tips 视频中介绍过的 函数式响应式 HTTP 风格。

package com.example.api;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;

import java.util.Map;

import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.web.reactive.function.server.ServerResponse.*;

@SpringBootApplication
public class ApiApplication {
    
    @Bean
    RouterFunction<ServerResponse> routes() {
        return route()
            .GET("/greetings", r -> ok().bodyValue(Map.of("greetings", "Hello, world!")))
            .build();
    }

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

运行应用程序,你将在 Netflix Eureka 实例中看到它的反映。你可以在 `application.properties` 中将 `server.port` 值更改为 `0`。如果你运行多个实例,你将在控制台中看到它们的反映。

负载均衡客户端

好的,现在我们准备演示负载均衡的实际应用。我们需要一个新的 Spring Boot 应用程序。前往 Spring Initializr,使用 `Eureka Discovery Client`、`Lombok`、`Cloud Loadbalancer` 和 `Reactive Web` 生成一个新项目。点击 `Generate` 并在你喜欢的 IDE 中打开项目。

将 Caffeine Cache 添加到类路径。它不在 Spring Initializr 上,所以我手动添加了它。它的 Maven 坐标是 `com.github.ben-manes.caffeine`:`caffeine`:`${caffeine.version}`。如果存在这个依赖,负载均衡器将使用它来缓存解析的实例。

我们来回顾一下我们想要实现的功能。我们希望调用我们的服务 `api`。我们知道负载均衡器中可能有多个服务实例。我们*可以*将 API 放在负载均衡器后面,然后就完成了。但我们想做的是利用关于每个应用程序状态的可用信息来做出更智能的负载均衡决策。有很多原因我们可能会使用客户端负载均衡器而不是 DNS。首先,Java DNS 客户端倾向于缓存解析的 IP 信息,这意味着后续对相同解析 IP 的调用最终会堆积在同一个服务上。你可以禁用缓存,但这与 DNS(一个以缓存为中心的系统)的工作方式背道而驰。DNS 只告诉你某个东西在*哪里*,而不是它*是否存在*。换句话说,你不知道在基于 DNS 的负载均衡器另一端是否会有任何东西等待你的请求。难道你不想在调用之前就能知道结果,从而避免客户端经历调用失败前的漫长超时吗?此外,一些模式,如服务对冲(这也是另一个 Spring Tips 视频的主题),只有通过服务注册中心才能实现。

我们来看一下 `client` 的常用配置属性。这些属性指定了 `spring.application.name`,这没什么新奇的。第二个属性很重要。它禁用了自 Spring Cloud 在 2015 年首次发布以来就一直存在的基于 Netflix Ribbon 的默认负载均衡策略。毕竟,我们想使用新的 Spring Cloud Load balancer。

spring.application.name=client
spring.cloud.loadbalancer.ribbon.enabled=false

所以,我们来看看服务注册中心的使用。首先,我们的客户端需要使用 Eureka `DiscoveryClient` 实现来建立与服务注册中心的连接。Spring Cloud `DiscoveryClient` 抽象在类路径上,所以它会自动启动并将 `client` 注册到服务注册中心。

这里是我们的应用程序的开头,一个入口点类。

package com.example.client;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction;
import org.springframework.cloud.client.loadbalancer.reactive.Response;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

import static com.example.client.ClientApplication.call;

@SpringBootApplication
public class ClientApplication {

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

我们将添加一个 DTO 类,用于将服务返回的 JSON 结构传递给客户端。这个类使用了 Lombok 的一些便利注解。

@Data
@AllArgsConstructor
@NoArgsConstructor
class Greeting {
    private String greetings;
}

现在,我们来看三种不同的负载均衡方法,每种方法都越来越复杂。

直接使用 Loadbalancer 抽象

第一种方法是三种方法中最简单的,虽然也是最冗长的。在这种方法中,我们将直接使用负载均衡抽象。组件注入一个指向 `ReactiveLoadBalancer.Factory` 的指针,然后我们可以使用它来获取一个 `ReactiveLoadBalancer`。这个 `ReactiveLoadBalancer` 是我们通过调用 `api.choose()` 来对 `api` 服务进行负载均衡调用的接口。然后我使用那个 `ServiceInstance` 来构建指向该特定 `ServiceInstance` 的主机和端口的 URL,然后使用 `WebClient`(我们的响应式 HTTP 客户端)发出 HTTP 请求。

@Log4j2
@Component
class ReactiveLoadBalancerFactoryRunner {

  ReactiveLoadBalancerFactoryRunner(ReactiveLoadBalancer.Factory<ServiceInstance> serviceInstanceFactory) {
        var http = WebClient.builder().build();
        ReactiveLoadBalancer<ServiceInstance> api = serviceInstanceFactory.getInstance("api");
        Flux<Response<ServiceInstance>> chosen = Flux.from(api.choose());
        chosen
            .map(responseServiceInstance -> {
                ServiceInstance server = responseServiceInstance.getServer();
                var url = "http://" + server.getHost() + ':' + server.getPort() + "/greetings";
                log.info(url);
                return url;
            })
            .flatMap(url -> call(http, url))
            .subscribe(greeting -> log.info("manual: " + greeting.toString()));

    }
}

发起 HTTP 请求的实际工作由我存放在应用程序类中的静态方法 `call` 完成。它需要一个有效的 `WebClient` 引用和一个 HTTP URL。


   static Flux<Greeting> call(WebClient http, String url) {
       return http.get().uri(url).retrieve().bodyToFlux(Greeting.class);
   }

这种方法可行,但发起一个 HTTP 调用需要*大量*代码。

使用 ReactorLoadBalancerExchangeFilterFunction

下一个方法将大部分模板式的负载均衡逻辑隐藏在一个 `WebClient` 过滤器中,该过滤器类型为 `ExchangeFilterFunction`,名为 `ReactorLoadBalancerExchangeFilterFunction`。我们在发起请求之前插入该过滤器,这样之前*大量*代码就消失了。

@Component
@Log4j2
class WebClientRunner {

    WebClientRunner(ReactiveLoadBalancer.Factory<ServiceInstance> serviceInstanceFactory) {

        var filter = new ReactorLoadBalancerExchangeFilterFunction(serviceInstanceFactory);

        var http = WebClient.builder()
            .filter(filter)
            .build();

        call(http, "http://api/greetings").subscribe(greeting -> log.info("filter: " + greeting.toString()));
    }
}

啊哈。好多了!但我们可以做得更好。

@LoadBalanced 注解

在最后一个示例中,我们将让 Spring Cloud 为我们配置 `WebClient` 实例。如果所有通过共享 `WebClient` 实例的请求都需要负载均衡,这种方法非常棒。只需为 `WebClient.Builder` 定义一个提供者方法,并用 `@LoadBalanced` 进行注解。然后你可以使用该 `WebClient.Builder` 定义一个 `WebClient`,它将自动为我们进行负载均衡。


    @Bean
    @LoadBalanced
    WebClient.Builder builder() {
        return WebClient.builder();
    }
    
    @Bean
    WebClient webClient(WebClient.Builder builder) {
        return builder.build();
    }

完成这些后,我们的代码几乎缩减到零。

@Log4j2
@Component
class ConfiguredWebClientRunner {

    ConfiguredWebClientRunner(WebClient http) {
        call(http, "http://api/greetings").subscribe(greeting -> log.info("configured: " + greeting.toString()));
    }
}

现在,这真是方便。

负载均衡器使用轮询负载均衡,它使用 `org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer` 策略将负载随机分发到多个已配置的实例中的任何一个。这种方法的好处是它是可插拔的。如果你愿意,也可以插入其他启发式算法。

下一步

在本期 Spring Tip 中,我们只是初步了解了负载均衡抽象,但我们已经实现了巨大的灵活性和简洁性。如果你对定制负载均衡器进一步感兴趣,可以研究一下 `@LoadBalancedClient` 注解。

订阅 Spring 新闻通讯

保持与 Spring 新闻通讯的连接

订阅

抢先一步

VMware 提供培训和认证,助你加速前进。

了解更多

获取支持

Tanzu Spring 通过一项简单的订阅即可提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

近期活动

查看 Spring 社区的所有近期活动。

查看全部