(18)企業採購招標系統之Ribbon結合RestTemplate實現負載均衡

JIAN2發表於2022-10-26

在上一篇教程中我們簡單地使用 Ribbon 進行了負載的一個呼叫,這意味著 Ribbon 是可以單獨使用的。推薦分散式架構原始碼。

在 Spring Cloud 中使用 Ribbon 會更簡單,因為 Spring Cloud 在 Ribbon 的基礎上進行了一層封裝,將很多配置都整合好了。本節將在 Spring Cloud 專案中使用 Ribbon。

使用 RestTemplate 與整合 Ribbon
Spring 提供了一種簡單便捷的模板類來進行 API 的呼叫,那就是 RestTemplate。

1.使用 RestTemplate
在前面介紹 Eureka 時,我們已經使用過 RestTemplate 了,本節會更加詳細地跟大家講解 RestTemplate 的具體使用方法。
首先我們來看看 GET 請求的使用方式:建立一個新的專案 spring-rest-template,配置好 RestTemplate:

[object Object]@Configuration
public class BeanConfiguration {
    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
}


新建一個 HouseController,並增加兩個介面,一個透過 @RequestParam 來傳遞引數,返回一個物件資訊;另一個透過 @PathVariable 來傳遞引數,返回一個字串。請儘量透過兩個介面組裝不同的形式,具體程式碼如下所示。

@GetMapping("/house/data")
public HouseInfo getData(@RequestParam("name") String name) {
    return new HouseInfo(1L, "上海" "虹口" "東體小區");
}
@GetMapping("/house/data/{name}")
public String getData2(@PathVariable("name") String name) {
    return name;
}


新建一個 HouseClientController 用於測試,使用 RestTemplate 來呼叫我們剛剛定義的兩個介面,程式碼如下所示。

@GetMapping("/call/data")
public HouseInfo getData(@RequestParam("name") String name) {
    return restTemplate.getForObject( ");
}
@GetMapping("/call/data/{name}")
public String getData2(@PathVariable("name") String name) {
    return restTemplate.getForObject( "{name}", String.class, name);
}



獲取資料結果可透過 RestTemplate 的 getForObject 方法(如下程式碼所示)來實現,此方法有三個過載的實現:

url:請求的 API 地址,有兩種方式,其中一種是字串,另一種是 URI 形式。
responseType:返回值的型別。
uriVariables:PathVariable 引數,有兩種方式,其中一種是可變引數,另一種是 Map 形式。

public <T> T getForObject(String url, Class<T> responseType, Object... uriVariables);
public <T> T getForObject(String url, Class<T> responseType, Map<String, ?> uriVariables);
public <T> T getForObject(URI url, Class<T> responseType);


除了 getForObject,我們還可以使用 getForEntity 來獲取資料,程式碼如下所示。

@GetMapping("/call/dataEntity")
public HouseInfo getData(@RequestParam("name") String name) {
    ResponseEntity<HouseInfo> responseEntity = restTemplate
            .getForEntity(");
    if (responseEntity.getStatusCodeValue() == 200) {
        return responseEntity.getBody();
    }
    return null;
}


getForEntity 中可以獲取返回的狀態碼、請求頭等資訊,透過 getBody 獲取響應的內容。其餘的和 getForObject 一樣,也是有 3 個過載的實現。

接下來看看怎麼使用 POST 方式呼叫介面。在 HouseController 中增加一個 save 方法用來接收 HouseInfo 資料,程式碼如下所示。

@PostMapping("/house/save")
public Long addData(@RequestBody HouseInfo houseInfo) {
    System.out.println(houseInfo.getName());
    return 1001L;
}



接著寫呼叫程式碼,用 postForObject 來呼叫,程式碼如下所示。

@GetMapping("/call/save")
public Long add() {
    HouseInfo houseInfo = new HouseInfo();
    houseInfo.setCity("上海");
    houseInfo.setRegion("虹口");
    houseInfo.setName("×××");
    Long id = restTemplate.postForObject(");
    return id;
}



postForObject 同樣有 3 個過載的實現。除了 postForObject 還可以使用 postForEntity 方法,用法都一樣,程式碼如下所示。

public <T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables);
public <T> T postForObject(String url, Object request, Class<T> responseType, Map<String, ?> uriVariables);
public <T> T postForObject(URI url, Object request, Class<T> responseType);


除了 get 和 post 對應的方法之外,RestTemplate 還提供了 put、delete 等操作方法,還有一個比較實用的就是 exchange 方法。exchange 可以執行 get、post、put、delete 這 4 種請求方式。更多地使用方式大家可以自行學習。

2.整合 Ribbon
在 Spring Cloud 專案中整合 Ribbon 只需要在 pom.xml 中加入下面的依賴即可,其實也可以不用配置,因為 Eureka 中已經引用了 Ribbon,程式碼如下所示。

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



RestTemplate 負載均衡示例
前面我們呼叫介面都是透過具體的介面地址來進行呼叫,RestTemplate 可以結合 Eureka 來動態發現服務並進行負載均衡的呼叫。

修改 RestTemplate 的配置,增加能夠讓 RestTemplate 具備負載均衡能力的註解 @LoadBalanced。程式碼如下所示。

@Configuration
public class BeanConfiguration {
    @Bean
    @LoadBalanced
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
}



修改介面呼叫的程式碼,將 IP+PORT 改成服務名稱,也就是註冊到 Eureka 中的名稱,程式碼如下所示。

@GetMapping("/call/data")
public HouseInfo getData(@RequestParam("name") String name) {
    return restTemplate.getForObject(");
}



介面呼叫的時候,框架內部會將服務名稱替換成具體的服務 IP 資訊,然後進行呼叫。 推薦分散式架構原始碼

@LoadBalanced 註解原理
相信大家一定有一個疑問:為什麼在 RestTemplate 上加了一個 @LoadBalanced 之後,RestTemplate 就能夠跟 Eureka 結合了,不但可以使用服務名稱去呼叫介面,還可以負載均衡?

應該歸功於 Spring Cloud 給我們做了大量的底層工作,因為它將這些都封裝好了,我們用起來才會那麼簡單。框架就是為了簡化程式碼,提高效率而產生的。

這裡主要的邏輯就是給 RestTemplate 增加攔截器,在請求之前對請求的地址進行替換,或者根據具體的負載策略選擇服務地址,然後再去呼叫,這就是 @LoadBalanced 的原理。

下面我們來實現一個簡單的攔截器,看看在呼叫介面之前會不會進入這個攔截器。我們不做任何操作,就輸出一句話,證明能進來就行了。具體程式碼如下所示。

public class MyLoadBalancerInterceptor implements ClientHttpRequestInterceptor {
    private LoadBalancerClient loadBalancer;
    private LoadBalancerRequestFactory requestFactory;
    public MyLoadBalancerInterceptor(LoadBalancerClient loadBalancer, LoadBalancerRequestFactory requestFactory) {
        this.loadBalancer = loadBalancer;
        this.requestFactory = requestFactory;
    }
    public MyLoadBalancerInterceptor(LoadBalancerClient loadBalancer) {
        this(loadBalancer, new LoadBalancerRequestFactory(loadBalancer));
    }
    @Override
    public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
            final ClientHttpRequestExecution execution) throws IOException {
        final URI originalUri = request.getURI();
        String serviceName = originalUri.getHost();
        System.out.println("進入自定義的請求攔截器中" + serviceName);
        Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri);
        return this.loadBalancer.execute(serviceName, requestFactory.createRequest(request, body, execution));
    }
}


攔截器設定好了之後,我們再定義一個註解,並複製 @LoadBalanced 的程式碼,改個名稱就可以了,程式碼如下所示。

@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface MyLoadBalanced {
}


然後定義一個配置類,給 RestTemplate 注入攔截器,程式碼如下所示。

@Configuration
public class MyLoadBalancerAutoConfiguration {
    @MyLoadBalanced
    @Autowired(required = false)
    private List<RestTemplate> restTemplates = Collections.emptyList();
    @Bean
    public MyLoadBalancerInterceptor myLoadBalancerInterceptor() {
        return new MyLoadBalancerInterceptor();
    }
    @Bean
    public SmartInitializingSingleton myLoadBalancedRestTemplateInitializer() {
        return new SmartInitializingSingleton() {
          @Override
          public void afterSingletonsInstantiated() {
            for (RestTemplate restTemplate : MyLoadBalancerAutoConfiguration.this.restTemplates){
                List<ClientHttpRequestInterceptor> list = new ArrayList<>(restTemplate.getInterceptors());
                list.add(myLoad BalancerInterceptor());
                restTemplate.setInterceptors(list);
            }
          }
        };
    }
}


維護一個 @MyLoadBalanced 的 RestTemplate 列表,在 SmartInitializingSingleton 中對 RestTemplate 進行攔截器設定。

然後改造我們之前的 RestTemplate 配置,將 @LoadBalanced 改成我們自定義的 @MyLoadBalanced,程式碼如下所示。

@Bean
//@LoadBalanced
@MyLoadBalanced
public RestTemplate getRestTemplate() {
    return new RestTemplate();
}


重啟服務,訪問服務中的介面就可以看到控制檯的輸出了,這證明在介面呼叫的時候會進入該攔截器,輸出如下:

進入自定義的請求攔截器中 ribbon-eureka-demo


透過這個小案例我們就能夠清楚地知道 @LoadBalanced 的工作原理。接下來我們來看看原始碼中是怎樣的一個邏輯。

首先看配置類,如何為 RestTemplate 設定攔截器,程式碼在 spring-cloud-commons.jar 中的 org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration 類裡面透過檢視 LoadBalancerAutoConfiguration 的原始碼,可以看到這裡也是維護了一個 @LoadBalanced 的 RestTemplate 列表,程式碼如下所示。

@LoadBalanced
@Autowired(required = false)
private List<RestTemplate> restTemplates = Collections.emptyList();
@Bean
public SmartInitializingSingleton loadBalancedRestTemplateInitializer(final List<RestTemplateCustomizer> customizers) {
    return new SmartInitializingSingleton() {
        @Override
        public void afterSingletonsInstantiated() {
            for (RestTemplate restTemplate : LoadBalancerAutoConfiguration.this.restTemplates) {
                for (RestTemplateCustomizer customizer : customizers) {
                    customizer.customize(restTemplate);
                }
            }
        }
    };
}


透過檢視攔截器的配置可以知道,攔截器用的是 LoadBalancerInterceptor,RestTemplate Customizer 用來新增攔截器,程式碼如下所示。

@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 new RestTemplateCustomizer() {
            @Override
            public void customize(RestTemplate restTemplate) {
                List<ClientHttpRequestInterceptor> list = new ArrayList<>(
                  restTemplate.getInterceptors());
                list.add(loadBalancerInterceptor);
                restTemplate.setInterceptors(list);
            }
        };
    }
}


攔截器的程式碼在 org.springframework.cloud.client.loadbalancer.LoadBalancerInterceptor 中,程式碼如下所示。

public class LoadBalancerInterceptor implements ClientHttpRequestInterceptor {
    private LoadBalancerClient loadBalancer;
    private LoadBalancerRequestFactory requestFactory;
    public LoadBalancerInterceptor(LoadBalancerClient loadBalancer, LoadBalancerRequestFactory requestFactory) {
        this.loadBalancer = loadBalancer;
        this.requestFactory = requestFactory;
    }
    public LoadBalancerInterceptor(LoadBalancerClient loadBalancer) {
        this(loadBalancer, new LoadBalancerRequestFactory(loadBalancer));
    }
    @Override
    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, requestFactory.createRequest(request, body, execution));
    }
}


主要的邏輯在 intercept 中,執行交給了 LoadBalancerClient 來處理,透過 LoadBalancer RequestFactory 來構建一個 LoadBalancerRequest 物件,程式碼如下所示。

public LoadBalancerRequest<ClientHttpResponse> createRequest(final HttpRequest request, final byte[] body,
        final ClientHttpRequestExecution execution) {
    return new LoadBalancerRequest<ClientHttpResponse>() {
        @Override
        public ClientHttpResponse apply(final ServiceInstance instance) throws Exception {
            HttpRequest serviceRequest = new ServiceRequestWrapper(request, instance, loadBalancer);
            if (transformers != null) {
                for (LoadBalancerRequestTransformer transformer : transformers) {
                    serviceRequest = transformer.transformRequest(serviceRequest,instance);
                }
            }
            return execution.execute(serviceRequest, body);
        }
    };
}


createRequest 中透過 ServiceRequestWrapper 來執行替換 URI 的邏輯,ServiceRequest Wrapper 中將 URI 的獲取交給了 org.springframework.cloud.client.loadbalancer.LoadBalancer Client#reconstructURI 方法。

以上就是整個 RestTemplate 結合 @LoadBalanced 的執行流程,至於具體的實現大家可以自己去研究,這裡只介紹原理及整個流程。

Ribbon API 使用
當你有一些特殊的需求,想透過 Ribbon 獲取對應的服務資訊時,可以使用 Load-Balancer Client 來獲取,比如你想獲取一個 ribbon-eureka-demo 服務的服務地址,可以透過 LoadBalancerClient 的 choose 方法來選擇一個:

@Autowired
private LoadBalancerClient loadBalancer;
@GetMapping("/choose")
public Object chooseUrl() {
    ServiceInstance instance = loadBalancer.choose("ribbon-eureka-demo");
    return instance;
}


訪問介面,可以看到返回的資訊如下:

{
    serviceId: "ribbon-eureka-demo",
    server: {
        host: "localhost",
        port: 8081,
        id: "localhost:8081",
        zone: "UNKNOWN",
        readyToServe: true,
        alive: true,
        hostPort: "localhost:8081",
        metaInfo: {
            serverGroup: null,
            serviceIdForDiscovery: null, instanceId: "localhost:8081",
            appName: null
        }
    },
    secure: false, metadata: { }, host: "localhost", port: 8081,
    uri: "
}


Ribbon 飢餓載入
筆者從網上看到很多部落格中都提到過的一種情況:在進行服務呼叫的時候,如果網路情況不好,第一次呼叫會超時。有很多大神對此提出瞭解決方案,比如把超時時間改長一點、禁用超時等。

Spring Cloud 目前正在高速發展中,版本更新很快,我們能發現的問題基本上在版本更新的時候就修復了,或者提供最優的解決方案。

超時的問題也是一樣,Ribbon 的客戶端是在第一次請求的時候初始化的,如果超時時間比較短的話,初始化 Client 的時間再加上請求介面的時間,就會導致第一次請求超時。

本教程是基於 Finchley.SR2 撰寫的,這個版本已經提供了一種針對上述問題的解決方法,那就是 eager-load 方式。透過配置 eager-load 來提前初始化客戶端就可以解決這個問題。

ribbon.eager-load.enabled=true
ribbon.eager-load.clients=ribbon-eureka-demo


ribbon.eager-load.enabled:開啟 Ribbon 的飢餓載入模式。
ribbon.eager-load.clients:指定需要飢餓載入的服務名,也就是你需要呼叫的服務,若有多個則用逗號隔開。
怎麼進行驗證呢?網路情況確實不太好模擬,不過透過除錯原始碼的方式即可驗證,在 org.springframework.cloud.netflix.ribbon.RibbonAutoConfiguration 中找到對應的程式碼,程式碼如下所示。

@Bean
@ConditionalOnProperty(value = "ribbon.eager-load.enabled")
public RibbonApplicationContextInitializer ribbonApplicationContextInitializer() {
    return new RibbonApplicationContextInitializer(springClientFactory(),ribbonEagerLoadProperties.getClients());
}


在 return 這行設定一個斷點,然後以除錯的模式啟動服務,如果能進入到這個斷點的程式碼這裡,就證明配置生效了 !



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70006413/viewspace-2920324/,如需轉載,請註明出處,否則將追究法律責任。

相關文章