【微服务专题】深入理解与实践微服务架构(八)之整合Ribbon负载均衡器

1,381

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第8篇文章,点击查看活动详情

什么是负载均衡器

A load balancer is a device that acts as a reverse proxy and distributes network or application traffic across a number of servers.Load balancersare used to increase capacity (concurrent users) and reliability of applications. — 摘自Google定义

(负载均衡器是一种充当反向代理并跨多个服务器分配网络或应用程序流量的设备。负载平衡器用于增加应用程序的容量(并发用户)和可靠性。)

也就是说负载均衡器是充当流量分配的主要工具,用于增加应用程序的并发性与可靠性。

负载均衡器的特性

参考自《维基百科》

不论是软件负载均衡器,还是硬件负载均衡器都有一系列的特性:

  • 不对称负载调节。可以对后台服务器设置权重因子,权重因子用于控制服务器的请求处理量,进而控制服务器的负载。当后台服务器的处理能力不是等同的时候,这是一种控制服务器负载的简单方法。
  • 优先启动。当出现故障的服务器达到某个阈值,或者服务器负载过高时,备用服务器必需可以及时上线提供服务。
  • SSL截断和加速。依赖服务器负载,处理加密数据或者通过SSL进行的授权请求会消耗Web服务器的大量CPU,随着需求增加用户会明显感觉到响应时间变长。为了消除Web服务器上这部分(处理加密)负载,负载均衡器可能会将SSL通讯截断在负载均衡器上。有些硬件负载均衡器上包含有专门用于处理SSL的硬件。当负载均衡器截断SSL连接请求,再将用户请求转发给后台前将HTTPS变为HTTP。只要负载均衡器不超载,这个特性不会影响用户体验。这种方法的负面效应是,由于所有SSL都由负载均衡器一台设备来处理,它会导致负载均衡器成为负载均衡体系的一个瓶颈。如果不使用这个特性,SSL请求将会分发给各个Web服务器处理。是否采用这一特性,需要分析比较两者的资金投入情况,含有处理SSL特殊硬件的负载均衡器通常价格高昂,而Web服务器一般比较廉价。增加少量的Web服务器的花费可能明显比升级负载均衡器要少。另外,一些服务器厂商如Oracle/Sun也开始在它们的CPU中加入加密加速模块,例如T2000。在负载均衡器上截断SSL的另一个优点是允许负载均衡器可以对基于HTTPS请求数据进行负载均衡或内容交换。
  • DDOS攻击防护。负载均衡器可以提供例如SYN cookies特性和延时绑定(在TCP握手完成之前,后台服务器不会与用户通讯)来减缓SYN flood攻击,并且通常将工作从服务器分载到更有效的平台。
  • HTTP压缩。使用gzipDeflate等算法压缩HTTP数据,以减少网络上的数据传输量。对于响应时间较长,传输距离较远的用户,这一特性对于缩短响应时间效果明显。这个特性会耗费负载均衡器更多的CPU,这一功能也可以由Web服务器来完成。
  • TCP offload。不同的厂商叫法可能不一样。其主要思想是一样的,通常每个用户的每个请求都会使用一个不同的TCP连接,这个特性利用HTTP/1.1将来自多个用户的多个请求合并为单个TCP socket再转发给后台服务器。
  • TCP缓冲。负载均衡器可以暂存后台服务器对客户的响应数据,再将它们转发给那些响应时间较长网速较慢的客户,如此后台Web服务器就可以释放相应的线程去处理其它任务如直接整个响应数据直接发送给网速较快的用户。
  • 后台服务器直接响应用户(Direct Server Return)。这是不对称负载分布的一项功能,在不对称负载分布中请求和回应通过不同的网络路径。
  • (服务器)健康检查。负载均衡器可以检查后台服务器应用层的健康状况并从服务器池中移除那些出现故障的服务器。
  • HTTP缓存。负载均衡器可以存储静态内容,当用户请求它们时可以直接响应用户而不必再向后台服务器请求。
  • 内容过滤。有些负载均衡器可以按要求修改通过它的数据。
  • HTTP安全。有些负载均衡器可以隐藏HTTP出错页面,删除HTTP响应头中的服务器标示信息,加密cookies以防止用户修改。
  • 优先队列。也可称之为流量控制。它可以对不同的内容设定不同的优先级。
  • 内容感知开关(Content-aware switching)。大多数负载均衡器可以基于用户请求的URL发送请求到不同的后台服务器,无论内容是加密(HTTPS)还是没有加密(HTTP)。
  • 用户授权。对来自不同身份验证源的用户进行验证,然后再允许他们访问一个网站。
  • 可编程的流量控制。不只一种负载均衡器允许使用脚本编程来定制负载均衡方法、任意的流量控制以及其它功能。
  • 防火墙功能。由于安全的原因,不允许用户直接访问后台服务器。防火墙是由一系列规则构成,它们决定着哪些请求可以通过一个接口而哪些不被允许。
  • 入侵阻止功能。在防火墙保障网络层/传输层安全的基础上,提供应用层安全防范。

负载均衡器的优点

  • 高性能:负载均衡技术将业务较均衡的分担到多台设备或链路上,从而提高了整个系统的性能;
  • 可扩展性:负载均衡技术可以方便的增加集群中设备或链路的数量,在不降低业务质量的前提下满足不断增长的业务需求;
  • 高可靠性:单个甚至多个设备或链路法神故障也不会导致业务中断,提高了整个系统的可靠性;
  • 可管理性:大量的管理共组都集中在使用负载均衡技术的设备上,设备集群或链路集群只需要维护通过的配置即可;
  • 透明性:对用户而言,集群等于一个或多个高可靠性、高性能的设备或链路,用户感知不到,也不关心具体的网络结构,增加或减少设备或链路数量都不会影响正常的业务。

负载均衡器的缺点

  • 如果没有足够的资源或配置不正确,负载均衡器可能会成为性能瓶颈。
  • 引入负载平衡器以帮助消除单点故障会导致复杂性增加。
  • 单个负载均衡器是单点故障,配置多个负载均衡器会进一步增加复杂性。

服务端负载均衡与客户端负载均衡

Spring Cloud不仅提供了使用Ribbon进行客户端负载均衡,还提供了Spring Cloud LoadBalancer。目前Netiflix已经停止维护Ribbon,最新的开源替代是LoadBalancer。相比较于Ribbon,Spring Cloud LoadBalancer不仅能够支持RestTemplate,还支持WebClient。WeClient是Spring Web Flux中提供的功能,可以实现响应式异步请求。

这两大开源组件都是基于客户端的负载均衡组件 ,实现了轮询、随机和权重请求等负载均衡算法。

那什么是服务端与客户端负载均衡呢? (以Nginx与Ribbon为例)

  • 服务端负载均衡:客户端所有请求都会交给nginx,然后由nginx实现转发请求,即负载均衡是由服务端实现
  • 客户端负载均衡:Ribbon在调用接口的时候从注册中心上获取注册信息服务列表,获取之后缓存在jvm本地,使用本地实现rpc远程技术进行调用;

目前业界主流的负载均衡方案可分成两类:

  • 集中式负载均衡:即在 consumer 和 provider 之间使用独立的负载均衡组件,由该设施负责把访问请求通过某种策略转发至 provider;
  • 进程内负载均衡:将负载均衡逻辑集成到 consumer,consumer 从服务注册中心获知有哪些地址可用,然后自己再从这些地址中选择出一个合适的 provider。

因此,服务端负载均衡也就是集中式负载均衡,客户端负载均衡也就是进程内负载均衡

负载均衡实现原理

下面主要浅析一下LoadBalancer负载均衡的实现过程与LoadBalancer负载均衡组件注册过程:

LoadBalancer负载均衡实现过程

img

可以看到,LoadBalancer的实现原理是:调用者通过注册中心获取所有的服务列表,然后通过LoadBalancer负载均衡算法选定一个服务提供者实例来进行请求。整个流程是:服务调用者 => Nacos => LoadBalancer => 服务提供者

Spring Cloud LoadBalancer负载均衡组件注册流程

img

可以看到,整个负载均衡组件生效过程中:首先,使用@LoadBalanced注解开启负载均衡;然后底层通过LoadBalancerInterceptor拦截器的intercept方法将请求拦截下来,然后通过LoadBalancerClientFactory工厂类的getInstance方法获取到serviceId客户端服务名对应的负载均衡策略,最后调用LoadBalancerClient客户端的execute方法执行具体的请求逻辑。简单来说就是,通过客户端上的拦截器实现请求分发负载均衡

因为Ribbon的依然作为负载均衡组件被大部分人群运用在各种生产环境中,而LoadBalancer只是作为Ribbon停止维护后的开源替代被使用(LoadBalancer作为Spring的嫡长子总有一天会完全替代Ribbon,只是时间问题),并且:

  • 目前 spring-cloud-loadbalancer 仅支持 重试操作的配置
  • ribbon 支持超时、懒加载处理、重试及其和 hystrix 整合高级属性等

因此,我们分别集成并测试两大客户端负载均衡组件Ribbon和LoadBalancer。

什么是Spring Cloud Ribbon

Spring Cloud Ribbon是 Netflix Ribbon 实现的一套客户端 负载均衡工具,作为老牌的客户端负载均衡组件一直承担流量分摊的核心。

简单的说,Ribbon 是 Netflix 发布的开源项目,主要功能是提供 客户端的复杂 均衡算法服务调用 Ribbon 客户端组件提供一系列完善的配置项如超时重试等。简单的说,就是配置文件中列出 load Balancer (简称 LB)后面所有的机器,Ribbon 会自动的帮助你基于某种规则(如简单轮询,随机链接等)去链接这些机器。

img

基于Ribbon以上这些强大的功能,我们下面集成并实践Ribbon的这些功能:

创建另一个服务提供者

首先需要明确一点,那就是Ribbon是位于服务提供者—服务调用者中的服务调用者一方,并且只有至少存在两个服务提供同一个接口时Ribbon的负载均衡策略才会生效。因此,基于之前我们创建过 一个service-provider-nacos 服务提供者了,因此这里再创建一个 service-provider-api 服务提供者,然后再创建一个 service-ribbon 服务消费者。

1. 创建服务提供者子模块

首先创建另一个 service-provider-api 服务提供者模块:

image-20220528132811588

2. 添加依赖

然后添加pom文件依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-alibaba-starter</artifactId>
        <groupId>com.deepinsea</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
​
    <artifactId>service-provider-api</artifactId>
​
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
​
    <dependencies>
        <!-- web 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- nacos 服务发现 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
            <version>2021.0.1.0</version>
        </dependency>
    </dependencies>
</project>

3. 添加主启动类服务发现声明

编写主启动类src/main/java/com/deepinsea/ServiceProviderApiApplication.java

package com.deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
​
/**
 * Created by deepinsea on 2022/5/28.
 * 服务提供者主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
public class ServiceProviderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceProviderApiApplication.class, args);
    }
}

4. 添加配置文件

server:
  # 服务运行端口
  port: 9040
spring:
  application:
    # 应用名称(为了测试负载均衡,与service-provider-nacos服务保持一致)
    name: service-provider-nacos
  cloud:
    nacos:
      discovery:
        # 服务名称(默认就是应用名)
        #        service: ${spring.application.name}
        # 服务注册地址
        server-addr: localhost:8848
        # Nacos认证信息
        username: nacos
        password: nacos
        # 注册到 nacos 的指定 namespace,默认为 public
        namespace: public

5. 创建服务提供Controller

controller 层中创建 ServiceProviderController 控制器,并添加与 service-provider-nacos中相同的服务接口:

package com.deepinsea.controller;
​
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
/**
 * Created by deepinsea on 2022/5/28.
 */
@RestController
@RequestMapping("/provider-nacos")
public class ServiceProviderController {
​
    @GetMapping("/hello")
    public String hello(){
        return "hi, this is service-provider-api!";
    }
​
}

这里只有打印信息有所差别(服务名和URL路由均相同),后面可以用于验证服务调用者的Ribbon负载均衡效果。

集成Ribbon负载均衡器

Ribbon是一个Netflix开源的客户端负载均衡器,其提供了丰富的负载均衡算法,服务消费者集成Ribbon后,Ribbon会自动从Nacos Server获取想要调用的服务的地址列表,通过负载均衡算法计算出一个实例交给RestTemplate调用。

注:Spring Cloud 2021 已移除对 Ribbon 的支持,相应的,Spring Cloud 2021 版本 Nacos 中 也删除了Ribbon 的 jar 包,实现时需要注意版本依赖关系

Nacos使用Ribbon的版本:Spring Cloud Hoxton.SR9 + Spring Cloud Alibaba 2.2.6.RELEASE

下面开始为Ribbon子模块添加ribbon依赖,并进行负载均衡相关配置,最后再测试负载均衡功能。

1. 创建Ribbon子模块

创建 service-ribbon 子模块:

image-20220528134946607

2. 添加依赖

这里推荐使用 spring-cloud-starter-netflix-ribbon ,因为具有内置 spring-cloud-starter-netflix-archaius 动态属性配置、com.google.code.findbugs 代码检查、Netty和RxJava等异步IO框架。如果只是纯粹只是想使用ribbon的核心功能,那么可以使用 spring-cloud-netflix-ribbon 核心依赖,也不会有下面的依赖冲突问题。

也可以这么说,Spring Cloud Ribbon是对 Netflix Ribbon的封装,Spring Cloud Starter Netflix Ribbon是对Spring Cloud Ribbon的进一步封装。

service-ribbon

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-alibaba-starter</artifactId>
        <groupId>com.deepinsea</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
​
    <artifactId>service-ribbon</artifactId>
​
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
​
    <dependencies>
        <!-- web 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- nacos 服务发现(兼容Ribbon) -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<!--            <version>2021.0.1.0</version>-->
            <version>2.2.6.RELEASE</version>
        </dependency>
        <!-- ribbon 负载均衡(spring cloud alibaba 2021.0.1.0已不再支持) -->
<!--        <dependency>-->
<!--            <groupId>org.springframework.cloud</groupId>-->
<!--            <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>-->
<!--            <version>2.2.6.RELEASE</version>-->
<!--        </dependency>-->
    </dependencies></project>

这里为什么将 spring-cloud-starter-alibaba-nacos-discovery 的版本下调到2.2.6并且注释ribbon依赖了呢?

  • 这是因为后面通过RestTemplate进行服务名称调用的时候,Nacos最新版本以及取消了对Ribbon的支持,也就是说Ribbon无法获取到InstanceId(服务名)从而无法使用负载均衡调用;因此,需要下调spring-cloud-alibaba版本来支持Ribbon的配置。
  • 第二个是因为spring-cloud-alibaba 2.2.6版本默认集成了 spring-cloud-starter-netflix-ribbon依赖,因此可以注释掉。

3. 添加主启动类服务发现声明

编写主启动类src/main/java/com/deepinsea/ServiceRibbonApplication.java

package com.deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
​
/**
 * Created by deepinsea on 2022/5/27.
 * Ribbon主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
public class ServiceRibbonApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceRibbonApplication.class, args);
    }
}

4. 创建yml配置文件

创建application.yml配置文件:

server:
  # 服务运行端口
  port: 9050
spring:
  application:
    # 应用名称
    name: service-ribbon
  cloud:
    nacos:
      discovery:
        # 服务名称(默认就是应用名)
        #        service: ${spring.application.name}
        # 服务注册地址
        server-addr: localhost:8848
        # Nacos认证信息
        username: nacos
        password: nacos
        # 注册到 nacos 的指定 namespace,默认为 public
        namespace: public
    loadbalancer:
      retry:
        # 开启重试机制
        enabled: true

通过配置文件,进行服务注册相关配置完成。

5. 添加负载配置类

因为通过服务名调用接口就需要用到客户端负载均衡器,这里我们使用 RestTemplate 进行请求和 @LoadBalancer 注解实现简单的负载配置:

package com.deepinsea.common.config;
​
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/5/28.
 * RestTemplate配置类
 */
@Configuration
public class RestTemplateConfig {
​
    // RestTemplate负载均衡配置
    @Bean
    @LoadBalanced // 使用服务名进行负载均衡
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

注意:可以直接使用 ip:端口 的方式请求服务提供者的接口,但是如果是以服务名的方式进行调用(一定会用到负载均衡组件,因为服务名是负载均衡组件从注册中心获取然后根据注册中心的ip:端口进行parse -- 当然你也可以实现这个逻辑),不使用负载均衡组件直接通过服务名调用的话会出现java.net.UnknownHostException: service-provider-nacos的错误,因此需要添加负载均衡组件进行服务名称的负载调用。

6. 创建服务调用Controller

我们按照演进顺序,从只使用RestTemplate,到使用RestTemplate+Ribbon,最后到OpenFeign+Ribbon三种方式分别进行调用。

创建RestTemplateController:

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/5/28.
 * Ribbon负载均衡接口类
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class RibbonController {
​
    @Autowired
    private RestTemplate restTemplate;
​
    // ip:端口
    private static final String url = "http://localhost:9010/provider-nacos/hello";
    // 服务名
    private static final String urlName = "http://service-provider-nacos/provider-nacos/hello";
​
    // 直接使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testURL")
    public String testURL(){
        String result = restTemplate.getForObject(url, String.class);
        return "直接调用返回信息:" + result;
    }
​
    // 使用RestTemplate + Ribbon进行调用(服务名)
    @PostMapping("/testName")
    public String testName(){
        String result = restTemplate.getForObject(urlName, String.class);
        return "Ribbon服务名调用返回信息:" + result;
    }
}

注意2021.0.1.0的Nacos不支持Ribbon获取服务名调用(也就是不支持Ribbon负载均衡) 。通过集成Ribbon实现服务名称方式的调用过程(依赖未变更前),可以发现:总是获取不到服务名称。一开始以为是依赖选择错误,结果两种依赖都报了获取不到服务名的错误:

  • spring-cloud-netflix-ribbon:java.net.UnknownHostException: service-provider-nacos
  • spring-cloud-starter-netflix-ribbon:java.lang.IllegalStateException: No instances available for service-provider

通过查询新版本的Nacos版本变更信息,在Spring Cloud Aliaba整合Ribbon的过程中,发现以下几个问题:

  1. nacos 2021 版本已经没有自带ribbon的整合,所以需要引入另一个支持的jar包 loadbalancer
  2. nacos 2021 版本已经取消了对ribbon的支持,所以无法通过修改Ribbon负载均衡的模式来实现nacos提供的负载均衡模式

但是直接使用基于IP的RestTemplate可以调用,因此,也就是说:最新版本的Nacos已不再支持Ribbon作为负载均衡器,而是默认支持LoadBalancer

那么,我们可以通过下调Spring-Cloud-Alibaba版本为2.2.6.RELEASE来解决Ribbon的支持问题,后面测试LoadBalancer组件负载均衡可以将依赖设置为最新版本的2021.0.1.0版本

依赖变更

        <!-- nacos 服务发现(兼容Ribbon) -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<!--            <version>2021.0.1.0</version>-->
            <version>2.2.6.RELEASE</version>
        </dependency>
        <!-- ribbon 负载均衡 -->
<!--        <dependency>-->
<!--            <groupId>org.springframework.cloud</groupId>-->
<!--            <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>-->
<!--            <version>2.2.6.RELEASE</version>-->
<!--        </dependency>-->

7. 测试服务调用

根据使用Ribbon和不使用Ribbon可以分为服务名调用和IP调用,服务名获取又可以分为@LoadBalanced和通过LoadBalancerClient获取以及通过 @RibbonClient获取。获取服务名的方式分别为负载均衡实现组件spring cloud commons+负载均衡实现组件以及ribbon

多种服务调用方式

① 使用RestTemplate服务调用

1.服务名称(负载均衡)方式调用

首先,测试RestTemplate通过@LoadBalanced注解(具体实现是Ribbon)生效负载均衡的调用方式:

C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testName
Ribbon服务名调用返回信息:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testName
Ribbon服务名调用返回信息:hi, this is service-provider-api!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testName
Ribbon服务名调用返回信息:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testName
Ribbon服务名调用返回信息:hi, this is service-provider-api!
cmd

可以看到4次调用都是轮询请求的,因此可以了解到:这两个相同服务名开启了负载均衡,并且使用的是默认的轮询策略。

因为对RestTemplate的Bean开启了负载均衡注解的原因,基于服务名的负载均衡调用是OK的!

注意:@LoadBalanced注解是org.springframework.cloud:spring-cloud-commons:3.1.1包下的client.loadbalancer模块,作用是通过具体的负载均衡组件从注册中心获取服务名,并转化为真实的请求IP地址,从而实现(服务名)负载均衡调用。

2.IP+端口方式调用

测试使用RestTemplate通过ip:端口的方式调用服务接口:

C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testURL
{"timestamp":"2022-05-28T18:52:29.991+00:00","status":500,"error":"Internal Server Error","path":"/consumer-ribbon/testURL"}
​
console控制台日志:java.lang.IllegalStateException: No instances available for localhost

发现,配置完RestTemplate使用负载均衡器调用后,服务名调用的方式失效了!

注意开启@LoadBalancer后,IP + 端口的方式调用服务会失效!!!

下面,解决RestTemplate同时支持ip和服务名调用的问题:

解决方案一:在注册的时候可以注册两个不同的RestTemplate,通过Bean方法名称区分

 @Bean   
 @LoadBalanced  
 public RestTemplate restTemplate() {      
  return new RestTemplate();   
 }  
​
@Bean    
public RestTemplate byIpRestTemplate() { 
       RestTemplate restTemplate = new RestTemplate();     
       restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
       // 上面的添加了UTF编码设置,防止中文乱码。
       return restTemplate;   
 }

注入的时候,注入与Bean方法名称相同的Bean即可区分两种RestTemplate Bean:

    @Autowired //ip:端口
    private RestTemplate byIpRestTemplate;
​
    // ip:端口
    private static final String url = "http://localhost:9010/provider-nacos/hello";
​
    // 直接使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testURL")
    public String testURL(){
        String result = byIpRestTemplate.getForObject(url, String.class);
        return "直接调用返回信息:" + result;
    }

再用byIpRestTemplate来发起请求,问题解决。

解决方案二:自己自定义或者产生一个名称不同的RestTemplate即可,通过name区别Bean

    @Bean(name = "ipRestTemplate") //使用ip:端口调用
    public RestTemplate ipRestTemplate() {
        return new RestTemplate();
    }

注入的时候,通过@Qualifier指定name注入的Bean:

    @Autowired
    @Qualifier("ipRestTemplate") //ip:端口
    private RestTemplate ipRestTemplate;
​
    // ip:端口
    private static final String url = "http://localhost:9010/provider-nacos/hello";
​
    // 使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testIP")
    public String testIP(){
        String result = ipRestTemplate.getForObject(url, String.class);
        return "IP调用返回信息:" + result;
    }

下面是使用RestTemplate基于服务名称和IP:端口的完整配置

RestTemplateConfig

package com.deepinsea.common.config;
​
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
​
import java.nio.charset.StandardCharsets;
​
/**
 * Created by deepinsea on 2022/5/28.
 * RestTemplate配置类
 */
@Configuration
public class RestTemplateConfig {
​
    @Bean
    @LoadBalanced // 使用服务名进行负载均衡
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }
​
    @Bean
    public RestTemplate byIpRestTemplate() { //使用ip:端口调用
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
        return restTemplate;
    }
​
    @Bean(name = "ipRestTemplate") //使用ip:端口调用
    public RestTemplate ipRestTemplate() {
        return new RestTemplate();
    }
}
​

RibbonController

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/5/28.
 * Ribbon负载均衡接口类
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class RibbonController {
​
    @Autowired //服务名
    private RestTemplate restTemplate;
​
    @Autowired //ip:端口
    private RestTemplate byIpRestTemplate;
​
    @Autowired
    @Qualifier("ipRestTemplate") //ip:端口
    private RestTemplate ipRestTemplate;
​
    // ip:端口
    private static final String url = "http://localhost:9010/provider-nacos/hello";
    // 服务名
    private static final String urlName = "http://service-provider-nacos/provider-nacos/hello";
​
    // 使用RestTemplate + Ribbon进行调用(服务名)
    @PostMapping("/testName")
    public String testName(){
        String result = restTemplate.getForObject(urlName, String.class);
        return "Ribbon服务名调用返回信息:" + result;
    }
​
    // 直接使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testURL")
    public String testURL(){
        String result = byIpRestTemplate.getForObject(url, String.class);
        return "直接调用返回信息:" + result;
    }
​
    // 使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testIP")
    public String testIP(){
        String result = ipRestTemplate.getForObject(url, String.class);
        return "IP调用返回信息:" + result;
    }
}

使用curl命令分别测试服务名称调用和IP调用:

C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testName
Ribbon服务名调用返回信息:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testURL
直接调用返回信息:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testIP
IP调用返回信息:hi, this is service-provider-nacos!

并且使用服务名调用时,控制台日志打印了Ribbon获取的服务列表:

2022-05-29 05:51:00.207  INFO 33176 --- [nio-9050-exec-2] c.n.l.DynamicServerListLoadBalancer      : DynamicServerListLoadBalancer for client service-provider-nacos initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=service-provider-nacos,current list of Servers=[192.168.174.1:9010],Load balancer stats=Zone stats: {unknown=[Zone:unknown;    Instance count:1;   Active connections count: 0;    Circuit breaker tripped count: 0;   Active connections per server: 0.0;]
},Server stats: [[Server:192.168.174.1:9010;    Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
]}ServerList:com.alibaba.cloud.nacos.ribbon.NacosServerList@3c59983e

兼容方案原理解析(实现同一个类型的Bean两种不同功能,并区别注入的两种功能的Bean)

这两种配置本质上没有差别:都是通过ByName的方式,声明相同Type的两个Bean

第一种,默认Bean加载的方法名就是Bean注入的名称,因此两个相同Type的Bean可以通过不同的方法名区分;第二种,就是使用@Qualifier注解指定了Bean的名称,这种方式是显式指定了bean的name(底层实现原理应该是:通过注解将Bean的默认方法名重写为声明的名称),当byType找不到时通过byName的方式找到Bean。

个人推荐第二种,因为两个相同方法名时(位于两个不同的配置类中,Bean方法可能会重复 -- 因为不同的服务之间其实相当于黑盒),运行后会出现Bean重复注入的错误:

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

测试:先注释RibbonController,将RibbonController中的TestIP接口(测试IP:端口调用的接口)复制到TestBeanController中用于测试Bean的名称冲突问题。

因为上面的RestTemplateConfig中有ipRestTemplate()方法了(同样需要和下面一样注释name声明),那么我们再模拟一个有相同方法名但显式指定bean的不同name,且Bean类型相同的环境:

TestBeanConfig

package com.deepinsea.common.config;
​
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/5/29.
 */
@Configuration
public class TestBeanConfig {
​
    @Bean //(name = "ipTestRestTemplate") //使用ip:端口调用(显式指定Bean名称),第二次正确测试用例时放开
    public RestTemplate ipRestTemplate() { // 方法名称相同
        return new RestTemplate();
    }
}

TestBeanController

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/5/29.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class TestBeanController {
​
    @Autowired
//    @Qualifier("ipRestTemplate") //ip:端口
    private RestTemplate ipRestTemplate;
​
    @Autowired
//    @Qualifier("ipTestRestTemplate") //第二次正确测试用例时放开
    private RestTemplate ipTestRestTemplate; //ip:端口(方法名称相同)
​
    // ip:端口
    private static final String url = "http://localhost:9010/provider-nacos/hello";
​
    // 使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testIP")
    public String testIP(){
        String result = ipRestTemplate.getForObject(url, String.class);
        return "IP调用返回信息:" + result;
    }
​
    // 使用RestTemplate进行调用(ip:端口)
    @PostMapping("/testBean")
    public String testBean(){
        String result = ipTestRestTemplate.getForObject(url, String.class);
        return "同Type不同名称Bean--IP调用返回信息:" + result;
    }
}

果然,因为Bean名称冲突,导致项目启动失败:

***************************
APPLICATION FAILED TO START
***************************
​
Description:
​
The bean 'ipRestTemplate', defined in class path resource [com/deepinsea/common/config/TestBeanConfig.class], could not be registered. A bean with that name has already been defined in class path resource [com/deepinsea/common/config/RestTemplateConfig.class] and overriding is disabled.Action:
​
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
​
​
Process finished with exit code 1

但是,将第二个Bean的name和注入时的@Qualifier声明注释放开,可以发现项目正常启动。进行接口调用测试,发现接口正常:

C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testIP
IP调用返回信息:hi, this is service-provider-nacos!
C:\Users\deepinsea>curl -d '' http://localhost:9050/consumer-ribbon/testBean
同Type不同名称Bean--IP调用返回信息:hi, this is service-provider-nacos!

因为显示声明Bean name与@Qualifier注入的方案,不需要通过不同的方法名生成一个默认name不同的Bean方法,并且声明的Bean name不需要与Bean方法名相同。因此,可见 @Qualifier声明Bean的name的方法解决Bean冲突的方案更佳

上面测试完毕后,可以将TestBeanConfig和TestBeanController注释,并且把RestTemplateTestController注释放开,以免影响到后续功能的集成和测试。

② 使用LoadBalancerClient服务调用

下面测试LoadBalancerClient服务调用,LoadBalancerClient是org.springframework.cloud.client:spring-cloud-commons:3.1.1包下的loadbalancer工具,可以通过具体负载均衡器组件的实现来进行服务名的调用。

效果与@LoadBalanced类似,但是可以获取到真实的IP参数。同样是依赖于负载均衡组件来进行服务名的调用,LoadBalancerClient为spring cloud的负载均衡公共抽象组件。这种调用方式RestTemplate不需要开启@LoadBalanced,本质是通过choose方法获取服务名的真实IP,然后使用RestTemplate进行真实IP调用.

有时间我会出一期关于@LoadBalaned和LoadBalancerClient的对比以及spring cloud的公共抽象组件的分析。

从spring cloud的负载均衡公共组件中我们可以知道(见名知意),服务负载均衡调用的源客户端类是LoadBalancerClient,它是继承了ServiceInstanceChooser 的实现;而 ServiceInstanceChooser 包含了 ServiceInstance ,而ServiceInstance是服务基本信息实体类。我们进一步查看下这个 spring-cloud-commons 公共抽象包下的所有实现类:按住Ctrl + Alt + B快速查看接口的实现类,可以得到两个实现类:BlockingLoadBalancerClientRibbonLoadBalancerClient

这两个组件分别是:spring-cloud-loadbalancerspring-cloud-netiflix-ribbon包下的。一个是spring cloud的内置负载均衡组件实现类,一个是外部netiflix-ribbon包下的负载均衡组件实现类。

其中分析BlockingLoadBalancerClient实现类的源码和关系结构,可知:

  1. 全局只有一个 BlockingLoadBalancerClient,负责执行所有的负载均衡请求。
  2. BlockingLoadBalancerClientLoadBalancerClientFactory里面加载对应微服务的负载均衡配置。
  3. 每个微服务下有独自的LoadBalancerLoadBalancer里面包含负载均衡的算法,例如RoundRobin.根据算法,从ServiceInstanceListSupplier返回的实例列表中选择一个实例返回。

我们关闭Ribbon之后,Spring Cloud LoadBalancer就会加载成为默认的负载均衡器。

但是,我们这里采用的是Ribbon作为负载均衡器,查看RibbonLoadBalancerClient的类继承关系图如下所示:

image-20220531202726170

因此我们知道了:要实现负载均衡调用客户端,必须先实现Spring Cloud的公共抽象类LoadBalancerClient

并且可以发现,LoadBalancerClient是作为负载均衡调用客户端的公共抽象类,起着承上启下的作用。因此,我们可以直接通过LoadBalancerClient类获取服务名绑定的服务基本信息,然后通过服务名调用的底层实现类ServiceInstance来进行服务调用;这样即使更换负载均衡实现组件,服务调用依然生效,这样做也就达到了与实现类无关的抽象解耦的效果

基于LoadBalancerClient进行服务名和IP的调用

创建LoadBalancerClientController:

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
​
/**
 * Created by deepinsea on 2022/6/3.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class LoadBalancerClientController {
​
    @Autowired //服务名
    private RestTemplate restTemplate;
​
    @Autowired //ip:端口
    private RestTemplate byIpRestTemplate;
​
    @Autowired //spring cloud commons负载均衡公共抽象,使用的前提是需要有负载均衡组件实现依赖(ribbon/loadbalancer)
    private LoadBalancerClient loadBalancerClient;
​
    @GetMapping("/testInstanceByName")
    public String testInstanceByName(){ //服务名调用(启用了负载均衡,路径中以服务名的方式调用的为同一服务,不推荐))
        ServiceInstance instance = loadBalancerClient.choose("service-provider-nacos");
//        System.out.println(instance.getUri());
//        System.out.println(instance.getMetadata());
//        System.out.println(instance.getInstanceId());
//        System.out.println(instance.getScheme()); //传输协议
//        System.out.println(instance.getServiceId());
//        System.out.println(instance.getHost());
//        System.out.println(instance.getPort());
        String result = restTemplate.getForObject("http://"+instance.getServiceId()+"/provider-nacos/hello", String.class);
        return "LoadBalancerClient获取服务名调用返回信息:" + result;
    }
​
    @GetMapping("/testInstanceByIp")
    public String testInstanceByIp(){ //IP调用(通过服务名获取到真实URI,然后负载均衡调用,推荐)
        ServiceInstance instance = loadBalancerClient.choose("service-provider-nacos");
        String result = byIpRestTemplate.getForObject(instance.getUri()+"/provider-nacos/hello", String.class);
        return "LoadBalancerClient获取Uri调用返回信息:" + result;
    }
}

通过curl命令进行服务名调用测试:

使用Ribbon获取服务名,然后通过LoadBalancerClient传递服务名给RestTemplate调用

> curl http://localhost:9050/consumer-ribbon/testInstanceByName
LoadBalancerClient获取服务名调用返回信息:hi, this is service-provider-nacos!
​
// console控制台日志
http://192.168.174.1:9010
{preserved.register.source=SPRING_CLOUD}
192.168.174.1:9010
null
service-provider-nacos
192.168.174.1
9010

服务名调用测试成功,但是这种URL中还是服务名的调用方式调用的还是同一个服务(不推荐) ,接下来是IP调用测试:

使用Ribbon获取服务名然后通过LoadBalancerClient传递IP和端口等参数给RestTemplate进行IP调用的测试

> curl http://localhost:9050/consumer-ribbon/testInstanceByIp
LoadBalancerClient获取Uri调用返回信息:hi, this is service-provider-api!
> curl http://localhost:9050/consumer-ribbon/testInstanceByIp
LoadBalancerClient获取Uri调用返回信息:hi, this is service-provider-nacos!

IP调用测试成功,并且成功开启了负载均衡策略

并且这种通过spring-cloud-commons公共抽象组件开启的负载均衡调用,本身不依赖于具体的负载均衡组件。也就是说引入任何一个实现Spring Cloud标准的负载均衡组件依赖都可以实现负载均衡调用,大大的解耦了对具体负载均衡组件的依赖性,是理解Spring Cloud底层原理的高手的调用选择方式!

@LoadBalanced:通过注解对调用客户端开启负载均衡调用,然后通过Ribbon的SpringClientFactory工厂类进行Ribbon负载均衡客户端对象的装载(亘古不变的原则工厂类生产客户端对象,生产出来的客户端对象负责具体调用)。

image-20220604003215758

我们分析下,SpringClientFactory工厂类中的服务列表参数时怎么获取的?

1.首先是通过RibbonClientConfiguration客户端配置类加载到默认的配置参数以及PropertiesFactory配置类工厂:

image-20220604010330186

2.然后在PropertiesFactory配置工厂通过HashMap存放五大核心配置类(ILoadBalancer、IPing、IRule、ServerList和ServerListFilter):

image-20220604011243822

其中就有存放服务列表的配置类ServerList,我们点进去进一步查看:

image-20220604011747180

3.可以看到,ServerList提供了初始化服务列表的接口和增量更新服务列表的顶级接口。

但是到此已经到最终类了(顶级接口),却没有看到具体调用参数过程,因此调用这个列获取到服务列表肯定是有默认的实现类。我们按住Ctrl + Alt + B查看ServerList的实现类,可以看到有AbstractServerList和NacosServerList,其实这里以及可以看出一些获取服务列表原理的端倪了(通过注册中心的服务列表类NacosServerList获取服务列表):

image-20220604011934017

但是我们知道,所有的spring cloud生态的实现类都遵守者spring cloud commons的规范(也就是说都是它抽象类的实现),我们点开服务列表的公共抽象类AbstractServerList:

image-20220604013436741

4.可以看到,AbstractServerList抽象服务列表类实现了ServerList类,而NacosServerList具体实现了这一抽象类:

image-20220604014128122

总结一下服务列表获取的类继承关系图,如下所示:

image-20220604012855362

5.我们查看Nacos注册中心的NacosServerList服务列表类,可以看到默认构造方法是通过NacosDiscoveryProperties配置类获取配置参数的:

image-20220604015134710

image-20220604024802355

查阅资料时,看到一个有意思的疑问:ServerList有多个实现,为什么注入的是NacosServerList?

博主的回答是:我们在使用nacos 或者eureka的时候,会导入他们两个包,比如spring-cloud-starter-nacos...eureka . 在他们包里面会有一个自动配置,nacos的是RibbonNacosAutoConfiguration,eureka的是RibbonEurekaAutoConfiguration 他们里面有个注解@RibbonClients 里面配置了会使用具体哪个配置。比如eureka使用EurekaRibbonClientConfiguration ,nacos使用NacosRibbonClientConfiguration,通过自动配置类中的依赖和类路径存在判断注解绝对启用哪一个自动配置类

其实,我觉得很好理解:不同版本的Nacos支持不同的负载均衡组件,文件目录已经说明了一切

image-20220604025756344

6.上面的NacosDiscoveryProperties配置文件只提供了服务调用者本地的服务配置参数,还需要服务提供者的serviceId来知道该调用那个服务,因此需要通过传入的serviceId参数显式声明服务提供者的服务名。

我们来通过打断点的方式跟进源码的运行步骤,来分析一下Nacos源码是怎么通过serviceId获取到服务列表的:

首先对NacosServerList的获取多个服务的getServers方法进行分析,对其中的核心方法 this.discoveryProperties.namingServiceInstance().selectInstances 进行断点调试,发现的serviceId入参正是我们传入的需要调用的服务名:

使用curl命令调用服务名调用接口,触发调试:

curl http://localhost:9050/consumer-ribbon/testInstanceByName

请求成功进到IDEA调试器里来了:

image-20220604032053906

我们调试步进到下一步,发现已经成功获取到需要调用的服务提供者的服务参数:

image-20220604041024357

但是如何获取到 List<Instance> 的细节封装在这个方法底层,我们选择进一步查看这个方法内部结构(点开 this.discoveryProperties.namingServiceInstance().selectInstances方法),发现是一个顶级接口:

image-20220604032911463

好像路到这里就断了,好像知道了调用细节,但好像还是比较迷蒙(这里我们听到顶级接口,马上用Ctrl + Alt + B快捷键查看实现类)。查看NamingService接口的实现类,发现只有一个实现类NacosNamingService:

image-20220604033410418

这个就和我们平时开发时的service层一样,调用service类的接口,最后真正执行的是implement类。实现类会重写父类的所有接口方法,通过添加@Override注解进行说明,当然也可以不写(正如Nacos的NacosNamingService源码中一样)。

那么我们来查看实现类对应的selectInstances方法,这里默认的subscribe值是true,即代表我这个消费者直接订阅了这个服务,因此最终的信息是从本地Map中获取,即Nacos维护了一个注册列表。

image-20220604041923638

点击两下 this.selectInstances() 构造方法,最后发现跳转到重写了方法体的全参数的 this.selectInstances() 构造方法中:

在这里插入图片描述

通过源码可以了解到:这应该就是从Nacos服务端拉取配置到本地,以及注册本地服务到远端的核心方法了。其中ServiceInfo服务信息是通过HostReactor主机响应器类来拉取和更新服务信息,那我们来分析一下HostReactor 中的 getServiceInfo( ) 方法:

public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {
        
        NAMING_LOGGER.debug("failover-mode: " + failoverReactor.isFailoverSwitch());
        // 获取远端服务提供者(单机和集群)
        String key = ServiceInfo.getKey(serviceName, clusters);
        //开启故障转移模式,从failoverReactor 获取服务列表
        if (failoverReactor.isFailoverSwitch()) {
            return failoverReactor.getService(key);
        }
        
        //从缓存中获取服务列表
        ServiceInfo serviceObj = getServiceInfo0(serviceName, clusters);
        
        if (null == serviceObj) {
            //本地服务信息不存在但拉取到远端存在的服务信息时,会创建新的服务信息,并放入缓存serviceInfoMap
            serviceObj = new ServiceInfo(serviceName, clusters);
            
            serviceInfoMap.put(serviceObj.getKey(), serviceObj);
            
            //记录服务正在更新中
            updatingMap.put(serviceName, new Object());
            //更新服务
            updateServiceNow(serviceName, clusters);
            //服务更新完删除缓存
            updatingMap.remove(serviceName);
            
        } else if (updatingMap.containsKey(serviceName)) {
            //服务正在更新中,wait 5s  UPDATE_HOLD_INTERVAL = 5000L
            if (UPDATE_HOLD_INTERVAL > 0) {
                // hold a moment waiting for update finish
                synchronized (serviceObj) {
                    try {
                        serviceObj.wait(UPDATE_HOLD_INTERVAL);
                    } catch (InterruptedException e) {
                        NAMING_LOGGER
                                .error("[getServiceInfo] serviceName:" + serviceName + ", clusters:" + clusters, e);
                    }
                }
            }
        }
        
        //服务更新任务不存在时,创建服务更新任务并调度
        scheduleUpdateIfAbsent(serviceName, clusters);
        
        return serviceInfoMap.get(serviceObj.getKey());
    }

最终所需要的结果是从serviceInfoMap中获取,并且通过多个Map(futureMap、serviceInfoMap和updatingMap -- 当然只是定义时定义为Map类型,实例化时是HashMap和两个ConcurrentHashMap)进行维护服务实例,若存在数据的变化,还会通过强制睡眠5秒钟的方式来等待数据的更新。

HostReactor的服务信息更新主要流程,如下所示:

  • 1、获取服务信息
  • 2、故障转移
  • 3、从缓存中获取服务信息
  • 4、从远程更新服务信息
  • 5、服务正在更新中
  • 6、定时更新服务

参考博客:HostReactor 服务更新流程

可以了解到源码中本地服务信息serviceInfoMap和远程服务信息serviceMap都是通过ConcurrentHashMap存储serviceInfoMap

扩展

@Override注解告诉你下面这个方法是从父类/接口继承过来的,需要你重写一次,这样就可以方便你阅读,也不怕会忘记。

@Override是伪代码,表示重写(当然不写也可以),不过写上有以下优点: 1、编译器可以给你验证@Override下面的方法名是否是你父类中所有的,如果没有则报错。 比如你如果没写@Override而你下面的方法名又写错了,这时你的编译器是可以通过的(它以为这个方法是你的子类中自己增加的方法)。所以使用@Override注解是为了增强程序在编译时候的检查,如果该方法并不是一个覆盖父类的方法,在编译时编译器就会报告错误。

2、可以当注释用,可读性提高 其他人在阅读你的代码的时候,就会更容易读懂,你自己隔一段时间回看代码也不会突然忘了该方法的含义。

查看NacosNamingService源码可知,NacosNamingService类应该为服务发现的核心类

整个服务调用实现负载均衡源码解析

服务发现过程: 经常有人说过,Nacos有个好处,就是当一个服务挂了之后,短时间内不会造成影响,因为有个本地注册列表,在服务不更新的情况下,服务还能够正常的运转,其原因如下:

1,Nacos的服务发现,一般是通过订阅的形式来获取服务数据。 2,而通过订阅的方式,则是从本地的服务注册列表中获取(可以理解为缓存)。相反,如果不订阅,那么服务的信息将会从Nacos服务端获取,这时候就需要对应的服务是健康的。(宕机就不能使用了) 3,在代码设计上,通过Map来存放实例数据,key为实例名称,value为实例的相关信息数据(ServiceInfo对象)。 最后,服务发现的流程就是:

1,以调用远程接口(OpenFeign)为例,当执行远程调用时,需要经过服务发现的过程。 2,服务发现先执行NacosServerList类中的getServers()方法,根据远程调用接口上@FeignClient中的属性作为serviceId传入NacosNamingService.selectInstances()方法中进行调用。 3,根据subscribe的值来决定服务是从本地注册列表中获取还是从Nacos服务端中获取。 4,以本地注册列表为例,通过调用HostReactor.getServiceInfo()来获取服务的信息(serviceInfo),Nacos本地注册列表由3个Map来共同维护。 本地Map–>serviceInfoMap, 更新Map–>updatingMap 异步更新结果Map–>futureMap, 最终的结果从serviceInfoMap当中获取。

5,HostReactor类中的getServiceInfo()方法通过this.scheduleUpdateIfAbsent() 方法和updateServiceNow()方法实现服务的定时更新和立刻更新。 6,而对于scheduleUpdateIfAbsent()方法,则通过线程池来进行异步的更新,将回调的结果(Future)保存到futureMap中,并且发生提交线程任务时,还负责更新本地注册列表中的数据。

总结

问题1:Nacos的服务注册为什么和spring-cloud-commons这个包扯上关系?

回答: 1.首先,Nacos的服务注册肯定少不了pom包:spring-cloud-starter-alibaba-nacos-discovery吧。 2.这个包下面包括了spring-cloud-commons包,那么这个包有什么用? 3.spring-cloud-commons中有一个接口叫做ServiceRegistry,而集成到SpringCloud中实现服务注册的组件,都需要实现这个接口。 4.因此对于需要注册到Nacos上的服务,也需要实现这个接口,那么具体的实现子类为NacosServiceRegistry。

问题2:为什么我的项目加了这几个依赖,服务启动时依旧没有注册到Nacos中?

回答: 1.本文提到过,进行Nacos服务注册的时候,会有一个事件的监听过程,而监听的对象是WebServer,因此,这个项目需要是一个Web项目! 2.因此查看你的pom文件中是否有依赖:spring-boot-starter-web。

问题3:除此之外,spring-cloud-commons这个包还有什么作用?

回答: 1.这个包下的spring.factories文件中,配置了相关的服务注册的置类,即支持其自动装配。 2.这个配置类叫做AutoServiceRegistrationAutoConfiguration。其注入了类AutoServiceRegistration,而NacosAutoServiceRegistration是该类的一个具体实现。 3.当WebServer初始化的时候,通过绑定的事件监听器,会实现监听,执行服务的注册逻辑。

③ 使用FeignClient调用

因为Spring Cloud Alibaba 2.2.6.RELEASE对应Spring Cloud Hoxton.SR9版本,也就是说是Greenwich(格林威治)的下一个版本,这个时候Feign已经停止维护了。因此我们需要集成的是OpenFeign,并且OpenFeign在Feign的基础上只有扩展和优化,并不像LoadBalancer那样丢失了部分功能。

首先,我们需要添加OpenFeign的依赖:

        <!-- openfeign 服务调用客户端 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

并且排除冲突的依赖:

        <!-- nacos 服务发现(兼容Ribbon) -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<!--            <version>2021.0.1.0</version>-->
            <version>2.2.6.RELEASE</version>
            <!-- 排除archaius-core冲突依赖 -->
            <exclusions>
                <exclusion>
                    <artifactId>archaius-core</artifactId>
                    <groupId>com.netflix.archaius</groupId>
                </exclusion>
            </exclusions>
        </dependency>

当然,如果你只想使用openfeign的核心功能,直接导入spring-cloud-openfeign就不会依赖冲突。

其次,我们需要在启动类中开启Feign客户端功能:

package com.deepinsea;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
​
/**
 * Created by deepinsea on 2022/5/27.
 * Ribbon主启动类
 */
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients //开启OpenFeign客户端
public class ServiceRibbonApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceRibbonApplication.class, args);
    }
}

然后,我们需要创建Feign客户端服务接口FeignClientService,用于接收Feign调用的客户端的接口:

package com.deepinsea.service;
​
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
​
/**
 * Created by deepinsea on 2022/6/16.
 * OpenFeign客户端服务接口
 * @FeignClient 声明当前接口为服务调用客户端
 */
@FeignClient(value = "service-provider-nacos") //需要调用的服务名
public interface FeignClientService {
​
    // hello接口
    @GetMapping("/provider-nacos/hello")
    String hello();
​
}

最后创建服务调用OpenFeignRibbonController,测试服务调用:

package com.deepinsea.controller;
​
import com.deepinsea.service.FeignClientService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import javax.annotation.Resource;
​
/**
 * Created by deepinsea on 2022/6/16.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class OpenFeignRibbonController {
​
    @Resource
    private FeignClientService feignClientService;
​
    @GetMapping("/testFeign")
    public String testFeign(){
        String result = feignClientService.hello();
        return "OpenFeign使用Ribbon进行服务名负载均衡调用" + result;
    }
}

完成后启动项目,但是发现项目启动失败,控制台提示我们是否引入了 spring-cloud-starter-loadbalancer 依赖:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'openFeignRibbonController': Injection of resource dependencies failed; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'com.deepinsea.service.FeignClientService': Unexpected exception during bean creation; nested exception is java.lang.IllegalStateException: No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-loadbalancer?

更换 spring-cloud-starter-openfeign 的版本为 2.2.6.RELEASE也不行,说明最新版本的Spring Cloud Alibaba和SpringCloud已经对停止维护的ribbon停止支持了。因此需要切换依赖为Spring Cloud Hoxton.SR9和Spring Cloud Alibaba 2.2.6.RELEASE,以使OpenFeign支持Ribbon作为负载均衡器。

我们因为其他的子模块以及依赖于最新的版本,因此我们可以尝试创建一个小型微服务进行测试,父模块依赖如下:

<properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <java.version>1.8</java.version>
        <!-- spring boot的版本 -->
        <spring-boot.version>2.3.2.RELEASE</spring-boot.version>
        <!-- spring cloud的版本 -->
        <spring-cloud.version>Hoxton.SR9</spring-cloud.version>
        <!-- spring cloud alibaba的版本 -->
        <spring-cloud-alibaba.version>2.2.6.RELEASE</spring-cloud-alibaba.version>
    </properties>
    <!-- 项目打包方式 -->
    <packaging>pom</packaging>
​
    <dependencyManagement>
        <dependencies>
            <!-- spring boot依赖 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- spring-cloud依赖 -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- alibaba推出的spring-cloud依赖的父依赖 -->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

然后分别创建生产者和消费者子模块,最后进行测试,发现是OK的。具体我们这里就展示了,毕竟耗时间,相信有上面的实践后很容易创建一个小型的Spring Cloud Alibaba微服务来测试OpenFeign使用Ribbon进行负载均衡调用。

④ 使用SpringClientFactory获取服务列表

深入了解源码后,实践源码相关原理的服务调用

首先需要知道的是,能够获取到服务列表和服务详情信息,那么也就意味着我们一定可以实现负载均衡调用。

不管是通过Ribbon的类获取服务列表,还是通过Spring Cloud Commons公共抽象获取服务列表,我们都可以通过获取的服务名对应的多个真实IP地址自定义负载均衡策略实现负载均衡调用。因此,这本身也可以说是一种调用方式,只不过是自定义的!

这一小节和下一小节获取服务列表的目的是:进一步探究负载均衡调用的背后实现原理

我们可以直接使用SpringClientFactory的getLoadBalancer()方法指定服务名(当然这种方式还是依赖于Ribbon),创建一个SpringClientFactoryController的测试控制器:

package com.deepinsea.controller;
​
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.ribbon.SpringClientFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import java.util.List;
​
/**
 * Created by deepinsea on 2022/6/5.
 * 测试 Ribbon的 SpringClientFactory获取服务信息
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class SpringClientFactoryController {
​
​
    @Autowired //ribbon的bean工厂
    private SpringClientFactory springClientFactory;
​
    @GetMapping("/getLBServerList")
    public String getLBServerList(){ //这里获取到的只是服务名相同的负载均衡服务信息
        ILoadBalancer loadBalancer = springClientFactory.getLoadBalancer("service-provider-nacos");
        List<Server> allServers = loadBalancer.getAllServers(); //所有服务列表
        List<Server> upServer = loadBalancer.getReachableServers(); //可用服务列表
        System.out.println(allServers);
        System.out.println(upServer);
        return "ribbon获取服务列表成功,负载均衡服务列表为:"+allServers;
    }
}

即可获取在注册中心的负载均衡的服务列表:

我们启动两个负载均衡子模块服务service-provider-nacos和service-provider-api

image-20220605021046920

然后使用curl命令请求接口:

curl http://localhost:9050/consumer-ribbon/getLBServerList
ribbon获取服务列表成功,负载均衡服务列表为:[192.168.174.1:9040, 192.168.174.1:9010]

注意 :获取请求服务的服务信息还是要依赖于注册中心,因为只有注册中心负责管理服务信息。

⑤ 使用DiscoveryClient获取服务列表

上面的@loadBalanced、LoadBalanerClient、SpringClientFacttory案例都是基于负载均衡的实现组件来获取服务列表的,虽然有一部分是Spring Cloud公共抽象而不是具体的组件实现,但是还是得引入负载均衡组件才能生效。而DiscoveryClient因为是注册中心公共抽象组件的原因,因此可以完全不用添加负载均衡组件,并且与注册中心的具体实现也无关

我在想,既然注册中心可以管理服务列表,Spring Cloud Commons公共抽象组件同样提供了微服务组件的功能,那么也就是说我们可以直接通过spring-cloud-commons包下的注册中心公共抽象组件中的获取服务列表功能的类,来获取服务列表:

创建DiscoveryClientController:

package com.deepinsea.controller;
​
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
​
import java.util.ArrayList;
import java.util.List;
​
/**
 * Created by deepinsea on 2022/6/5.
 */
@RestController
@RequestMapping("/consumer-ribbon")
public class DiscoveryClientController {
​
    @Autowired //spring-cloud-commons的注册中心公共抽象
    private DiscoveryClient discoveryClient;
​
    /**
     * 获取nacos上注册的所有同服务名的实例详情
     *
     * @return
     */
    @GetMapping("/getServiceInstance")
    public String getServiceInstance() {
        //这里查询的服务名即为nacos控制面板上的服务名
        List<ServiceInstance> serviceInstanceList = discoveryClient.getInstances("service-provider-nacos");
        List list = new ArrayList();
        serviceInstanceList.forEach(serviceInstance -> {
            //获取host
            list.add(serviceInstance.getHost());
            //获取端口号
            list.add(serviceInstance.getPort());
            list.add(serviceInstance.getServiceId());
        });
        return "服务列表为:" + list;
    }
}

使用curl命令验证与测试(我们将spring-cloud-alibaba版本切换为2021.0.1.0,然后注释@LoadBalanced注解):

> curl http://localhost:9050/consumer-ribbon/getServiceInstance
服务列表为:[192.168.174.1, 9040, service-provider-nacos, 192.168.174.1, 9010, service-provider-nacos]

可以看到,没有了Ribbon获取服务列表和服务信息的功能还是生效。spring-cloud-commons下的注册中心公共抽象组件也可以实现服务查询的功能,因为Nacos实现了spring cloud commons的注册中心标准。整个负载均衡调用过程,大概如下:

Nacos => spring cloud commons包下的Abstract接口 => ribbon

因此,我们可以理解为其实spring cloud commons都可以持有一份nacos的服务列表信息。

这种直接调用spring-cloud-commons包下的功能抽象的方式,有如下优点:

  • 避免了更换注册中心后接口需要重新实现的繁琐
  • 也解决了一定要添加负载均衡组件通过负载组件进行负载调用才能以服务名进行调用的僵硬问题;
  • 甚至这种方式调用我们可以直接封装和自定义自己的负载均衡框架

因此,这种方式应该是最佳的(当然,前提是你得理解Spring Cloud公共抽象组件的源码)。

Nacos服务列表和服务详情信息验证与查询

我们可以抓包nacos控制台提供的服务列表请求接口,得到服务列表查询接口如下所示:

http://localhost:8848/nacos/v1/ns/catalog/services?hasIpCount=true&withInstances=false&pageNo=1&pageSize=10&serviceNameParam=&groupNameParam=&accessToken=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuYWNvcyIsImV4cCI6MTY1MTE0MDQ3MX0.5M58DgbvFMJHhmcZzTz5BIjEykOQDy2A3yVj_aDAqlg&namespaceId=

咱们简化下,只查询关键参数,得到如下地址:

http://localhost:8848/nacos/v1/ns/catalog/services?pageNo=1&pageSize=10

开启一个服务,在浏览器进行请求查看:

image-20220605002820794

成功获取到服务列表!

使用postman请求是OK的:

image-20220605003748102

同样的,使用curl命令也可以(url地址记得带上" "双引号):

C:\Users\deepinsea>curl "http://localhost:8848/nacos/v1/ns/catalog/services?pageNo=1&pageSize=10"
{"serviceList":[{"name":"service-provider-nacos","groupName":"DEFAULT_GROUP","clusterCount":1,"ipCount":1,"healthyInstanceCount":1,"triggerFlag":"false"}],"count":1}
​
# Json格式化后
{
    "serviceList": [
        {
            "name": "service-provider-nacos",
            "groupName": "DEFAULT_GROUP",
            "clusterCount": 1,
            "ipCount": 1,
            "healthyInstanceCount": 1,
            "triggerFlag": "false"
        }
    ],
    "count": 1
}

这个接口是Nacos查询服务列表的接口,但是这并不是服务实际详情的接口,服务详情接口为:

http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=service-provider-nacos

服务信息接口需要携带正确的且服务存活的serviceName参数才可以正常访问到服务的实时信息,通过Nacos Service提供的服务发现接口可以查询服务信息:

C:\Users\deepinsea>curl -X GET "http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=service-provider-nacos"
{"hosts":[{"ip":"192.168.174.1","port":9010,"valid":true,"healthy":true,"marked":false,"instanceId":"192.168.174.1#9010#DEFAULT#DEFAULT_GROUP@@service-provider-nacos","metadata":{"preserved.register.source":"SPRING_CLOUD"},"enabled":true,"weight":1.0,"clusterName":"DEFAULT","serviceName":"service-provider-nacos","ephemeral":true}],"dom":"service-provider-nacos","name":"DEFAULT_GROUP@@service-provider-nacos","cacheMillis":3000,"lastRefTime":1654362582358,"checksum":"45f4f7ad670a06cad3c6a20d920e63e2","useSpecifiedURL":false,"clusters":"","env":"","metadata":{}}
​
# Json格式化后
{
    "hosts": [
        {
            "ip": "192.168.174.1",
            "port": 9010,
            "valid": true,
            "healthy": true,
            "marked": false,
            "instanceId": "192.168.174.1#9010#DEFAULT#DEFAULT_GROUP@@service-provider-nacos",
            "metadata": {
                "preserved.register.source": "SPRING_CLOUD"
            },
            "enabled": true,
            "weight": 1.0,
            "clusterName": "DEFAULT",
            "serviceName": "service-provider-nacos",
            "ephemeral": true
        }
    ],
    "dom": "service-provider-nacos",
    "name": "DEFAULT_GROUP@@service-provider-nacos",
    "cacheMillis": 3000,
    "lastRefTime": 1654362582358,
    "checksum": "45f4f7ad670a06cad3c6a20d920e63e2",
    "useSpecifiedURL": false,
    "clusters": "",
    "env": "",
    "metadata": {}
}

存活的正确的serviceName名称的服务信息,如下所示:

image-20220605012110084

我们可以根据注册中心来获取服务列表,并通过服务列表的服务名称来获取到每一个服务的服务信息,就可以进行负载均衡调用了。

总结:本质是注册中心@Value读取到配置文件的服务参数,然后装载到NacosServerList里,其他负载均衡器就可以根据服务列表的参数进行装配进工厂类并进行服务名和IP的Parser转换操作,从而实现负载均衡功能。只不过不同版本的Nacos注册中心默认支持负载均衡器不同(通过自动配置类进行参数自动配置),因此可以实现一个@LoadBalanced就可以开启负载均衡的效果。

下一篇,我们将进行手写负载均衡算法和优化Ribbon配置部分的实践:

欢迎点赞,谢谢大佬ヾ(◍°∇°◍)ノ゙