优秀的编程知识分享平台

网站首页 > 技术文章 正文

阿里p7大佬首次分享Spring Cloud学习笔记,带你从0搭建微服务

nanyue 2024-11-24 19:44:52 技术文章 1 ℃

今日分享开始啦,请大家多多指教~

微服务架构三大要素,业务建模、技术体系和研发过程。

微服务架构的第一要素:业务建模

为什么我们首先需要考虑的是这个要素呢?因为微服务架构与传统 SOA 等技术体系有本质区别,就是其服务的粒度和服务本身的面向业务和组件化特性。针对服务建模,我们首先需要明确服务的类别,以及服务与业务之间的关系,尽可能明确领域的边界。

针对服务建模,推荐使用领域驱动设计(Domain Driven Design,DDD)方法,通过识别领域中的各个子域、判断这些子域是否独立、考虑子域与子域的交互关系,从而明确各个界限上下文(Boundary Context)之间的边界。

对于领域的划分,业界主流的分类方法认为,系统中的各个子域可以分成核心子域、支撑子域和通用子域三种类型,其中系统中的核心业务属于核心子域,专注于业务某一方面的子域称为支撑子域,可以作为某种基础设施的功能可以归到通用子域。下面以电商系统为例子。

务围绕业务能力建模,而业务能力往往体现的是一种分层结构。按照我的经验,我们可以把业务体系中的服务分成如下几种类型:基础服务、通用服务、定制服务和其他服务等。这里,我们同样给出基于电商场景的业务服务分层示例图,如下所示:

每个行业、每个公司具有不同的业务体系和产品形态,我无意对业务建模的应用场景做过多展开。但在课程的后续内容中,我们会基于 DDD 设计思想,并通过一个具体的案例来介绍如何完成对系统的业务建模,以帮助你在日常开发过程中掌握如何使用 DDD 来完成对业务系统的领域建模的系统方法。

微服务架构的第二要素:技术体系

在本课程中,我也基于目前业界主流的微服务实现技术提炼了八大技术体系,包括服务通信、服务治理、服务路由、服务容错、服务网关、服务配置、服务安全和服务监控。

服务通信

对于微服务架构而言,我们关注的是网络连接模式、I/O 模型和服务调用方式。

我们知道基于TCP 协议的网络连接有两种基本方式,也就是通常所说的长连接和短连接。 Dubbo 框架就采用的是长连接,而本课程中要介绍的 Spring Cloud 则采用了短连接。

服务之间通信的另一个关注点是 I/O 模型。I/O 模型也有阻塞式 I/O 和非阻塞式 I/O 等多种实现方式。以服务网关而言,像Netflix 的 Zuul就是阻塞式 I/O,而Spring 自研的 Spring Cloud Gateway则采用的是非阻塞式 I/O。

服务通信的另一个主题是调用方式,这方面同样存在同步调用和异步调用两大类实现机制。为了简化开发人员的使用过程,通常都会采用异步转同步的实现机制,也就是说开发人员使用同步的方式进行方法调用,而框架本身会基于 Future 等机制实现异步的远程处理。

服务治理

服务注册中心是保存服务调用所需的路由信息的存储仓库,也是服务提供者和服务消费者进行交互的媒介,充当着服务注册和发现服务器的作用。诸如 Dubbo、Spring Cloud 等主流的微服务框架都基于 Zookeeper、Eureka 等分布式系统协调工具构建了服务注册中心。

服务路由

Spring Cloud 等主流的微服务框架也都内置了 Ribbon 等客户端负载均衡组件。

另一方面,负载均衡的出发点更多的是提供服务分发而不是解决路由问题,常见的静态、动态负载均衡算法也无法实现精细化的路由管理。这时候我们就可以采用路由规则。路由规则常见的实现方案是白名单或黑名单,即把需要路由的服务地址信息(如服务 IP)放入可以控制是否可见的路由池中进行路由。同样,路由规则也是微服务开发框架的一项常见功能。

服务容错

业界存在一批与服务容错相关的技术组件,包括以失效转移 Failover 为代表的集群容错策略,以线程隔离、进程隔离为代表的服务隔离机制,以滑动窗口、令牌桶算法为代表的服务限流机制,以及服务熔断机制。而从技术实现方式上看,在 Spring Cloud 中,这些机制部分包含在下面要介绍的服务网关中,而另一部分则被提炼成单独的开发框架,例如专门用于实现服务熔断的 Spring Cloud Circuit Breaker 组件。

服务网关

服务网关的核心要点是,所有的客户端和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。

在功能设计上,服务网关在完成客户端与服务器端报文格式转换的同时,它可能还具有身份验证、监控、缓存、请求管理、静态响应处理等功能。另一方面,也可以在网关层制定灵活的路由策略。针对一些特定的 API,我们需要设置白名单、路由规则等各类限制。在本课程中,我们会基于 Netflix Zuul 和 Spring Cloud Gateway 这两种网关对这些功能分别展开介绍。

服务配置

在微服务架构中,考虑到服务数量和配置信息的分散性,一般都需要引入配置中心的设计思想和相关工具。与注册中心一样,配置中心也是微服务架构中的基础组件,其目的也是对服务进行统一管理,区别在于配置中心管理的对象是配置信息而不是服务的实例信息。

为了满足以上要求,配置中心通常需要依赖分布式协调机制,即通过一定的方法确保配置信息在分布式环境中的各个服务中能得到实时、一致的管理。可以采用诸如 Zookeeper 等主流的开源分布式协调框架来构建配置中心。当然,像 Spring Cloud 也提供了专门的配置中心实现工具 Spring Cloud Config。

服务安全

一般意义上的访问安全性,都是围绕认证和授权这两个核心概念来展开的。也就是说我们首先需要确定用户身份,然后再确定这个用户是否有访问指定资源的权限。站在单个微服务的角度讲,我们系统每次服务访问都能与授权服务器进行集成以便获取访问 Token。站在多个服务交互的角度讲,我们需要确保 Token 在各个微服务之间的有效传播。另一方面,服务内部,我们可以使用不同的访问策略限制服务资源的访问。

在实现微服务安全访问上,我们通常使用 OAuth2 协议来实现对服务访问的授权机制,使用 JWT 技术来构建轻量级的认证体系。Spring 家族也提供了 Spring Security 和 Spring Cloud Security 框架来完整这些组件的构建。

服务监控

在微服务架构中,当服务数量达到一定量级时,我们难免会遇到两个核心问题。一个是如何管理服务之间的调用关系?另一个是如何跟踪业务流的处理过程和结果?这就需要构建分布式服务跟踪机制。

分布式服务跟踪机制的建立需要完成调用链数据的生成、采集、存储及查询,同时也需要对这些调用链数据进行运算和可视化管理。这些工作不是简单一个工具和框架能全部完成,因此,在开发微服务系统时,我们通常会整合多个开发框架进行链路跟踪。例如,在 Spring Cloud 中,就提供了 Spring Cloud Sleuth 与 Zipkin 的集成方案。

微服务架构的第三要素:研发过程

Martin Fowler 在介绍微服务架构时,同样也提出了围绕“业务功能”组织团队的研发管理理念。

当寻找把一个大的应用程序进行拆分的方法时,研发过程通常都会围绕产品团队、项目管理、大前端和服务器端团队而展开,这些团队也就是通常所说的职能团队。任何一个需求,无论大小,都将导致跨团队协作,从而增加沟通和协作成本。

而微服务架构则倾向围绕业务功能的组织来分割服务,而不是面向某项技术能力。因此,团队是跨职能的特征团队,每个服务都围绕着业务进行构建,并且能够被独立部署到生产环境。这部分内容并不是本课程的重点,我们不做进一步展开。

Spring Cloud介绍

Spring Cloud 所基于的 Spring Boot,已经成为 Java EE 领域中最流行的开发框架,用来简化 Spring 应用程序的框架搭建和开发过程。

在设计思想上,Spring Boot 充分利用约定优于配置(Convention over Configuration)的自动化配置机制。与传统的 Spring 应用程序相比, Spring Boot 在启动依赖项自动管理、简化部署并提供应用监控等方面对开发过程做了优化。

Spring Cloud 中的组件非常多,我们无意对所有组件都进行详细展开,而是梳理了开发一个微服务系统所必需的八大核心组件。如下图所示。

案例驱动

在物联网和智能穿戴式设备日益发达的当下,试想一下这样的日常场景,患者通过智能手环、便携式脉诊仪等一些智能穿戴式设备检测自身的各项健康信息,然后把这些健康信息实时上报到云平台,云平台检测到用户健康信息中的异常情况时会通过人工或自动的方式进行一定的健康干预,从而确保用户健康得到保证。这是大健康领域非常典型的一个业务场景,也是我们案例的来源。

领域驱动

从领域建模的角度进行分析,我们可以把该系统分成三个子域,即:

  1. 用户(User)子域,用于用户管理,用户可以通过注册成为系统用户,同时也可以修改或删除用户信息,并提供用户信息有效性验证的入口。
  2. 设备(Device)子域,用于设备管理,医护人员可以查询某个用户的某款穿戴式设备以便获取设备的详细信息,同时基于设备获取当前的健康信息。
  3. 健康干预(Intervention)子域,用于健康干预管理,医护人员可以根据用户当前的健康信息生成对应的健康干预。当然,也可以查询自己所提交健康干预的当前状态。

从子域的分类上讲,用户子域比较明确,显然应该作为一种通用子域。而健康干预是 SpringHealth 的核心业务,所以应该是核心子域。至于设备子域,在这里比较倾向于归为支撑子域。

基于以上分析,我们可以把 SpringHealth 划分成三个微服务,即 user-service、device-service 和 intervention-service。下图展示了 SpringHealth 的基本架构,在图中,intervention-service 需要基于 REST 风格完成与 user-service 和 device-service 服务之间的远程交互。

服务设计

服务列表

当我们采用 Spring Cloud 构建完整的微服务技术解决方案时,部分技术组件需要通过独立服务的形式进行运作,具体包括:

(1)注册中心服务。我们将这个服务命名为 eureka-server。

(2)配置中心服务。我们将这个服务命名为 config-server。

(3)API网关服务。针对 Zuul 和 Spring Cloud Gateway 这两款工具,我们建立两个独立的 zuul-server 和 gateway-server 服务,并根据需要分别采用其中的一个服务进行运行。

(4)安全授权服务。我们把这个服务命名为 auth-server。

(5)案例中最后一个基础设施类服务是 Zipkin 服务,这个服务并不是必需的,而是取决于我们是否需要对服务访问链路进行可视化展示。因此将构建一个独立的 zipkin-server 服务。

以上这种划分只是一种场景。

服务数据

在案例中,我们针对三个业务服务,也将建立独立的三个数据库,数据库的访问信息通过配置中心进行集中管理,如下图所示:

使用 Spring Cloud 实现服务治理

服务治理

从架构设计上讲,状态变更管理可以采用发布-订阅模式,体现在服务提供者可以根据服务定义发布服务,而服务消费者则通过对自己感兴趣的服务进行订阅并获取包括服务地址在内的各项元数据。发布-订阅功能还体现在状态变更推送,即当注册中心服务定义发生变化时,主动推送变更到该服务的消费者。

基于发布-订阅设计思想,就诞生了一种服务监听机制。服务监听机制确保服务消费者能够实时监控服务更新状态,是一种被动接收变更通知的实现方案,通常采用监听器以及回调机制,如下图所示。

服务注册

基于 Eureka 构建注册中心(不建议使用,厂家放弃更新)

构建单点 Eureka 服务器

新建 Maven 工程并命名为 eureka-server。同时我们引入了 spring-cloud-starter-eureka-server 依赖,该依赖是 Spring Cloud 中实现 Spring Cloud Netflix Eureka 功能的主体 jar 包:

创建 Spring Boot 的启动类 EurekaServerApplication,代码如下所示。包含 @EnableEurekaServer 注解的服务意味着就是一个 Eureka 服务器组件。

Eureka 也为开发人员提供了一系列的配置项。这些配置项可以分成三大类,一类用于控制 Eureka 服务器端行为,以 eureka.server 开头;一类则是从客户端角度出发考虑配置需求,以 eureka.client 开头;而最后一类则关注于注册到 Eureka 的服务实例本身,以 eureka.instance 开头。请注意,Eureka 除了充当服务器端组件之外,实际上也可以作为客户端注册到 Eureka 本身,这时候它使用的就是客户端配置项。

现在,我们尝试在 eureka-server 工程的 application.yml 文件中添加了如下配置信息。我们不希望 Eureka 服务对自身进行注册,registerWithEureka、fetchRegistry都设置为false。

构建 Eureka 服务器集群

我们通常都需要构建一个 Eureka 服务器集群来确保注册中心本身的可用性。与传统的集群构建方式不同,如果我们把 Eureka 也视为一个服务,也就是说 Eureka服务自身也能注册到其他 Eureka 服务上,从而实现相互注册,并构成一个集群。在 Eureka中,这种实现高可用的部署方式被称为 Peer Awareness 模式。

现在我们准备两个 Eureka 服务实例 eureka1 和 eureka2。在 Spring Boot 中,我们分别提供 application-eureka1.yml 和 application-eureka2.yml 这两个配置文件来设置相关的配置项。其中 application-eureka1.yml 配置文件的内容如下:

对应的,application-eureka2.yml 配置文件的内容如下:

构建 Eureka 集群模式的关键点在于使用客户端配置项 eureka.client.serviceUrl.defaultZone 用于指向集群中的其他 Eureka 服务器。所以 Eureka 集群的构建方式实际上就是将自己作为服务并向其他注册中心注册自己,这样就形成了一组互相注册的服务注册中心以实现服务列表的同步。显然,这个场景下 registerWithEureka 和 fetchRegistry配置项应该都使用其默认的 true 值,所以我们不需要对其进行显式的设置。

如果你尝试使用本机搭建集群环境,显然 eureka.instance.hostname 配置项中的 eureka1 和 eureka2 是无法访问的,所以需要在本机hosts 文件中添加以下信息。

127.0.0.1 eureka1

127.0.0.1 eureka2

理解 Eureka 服务器实现原理

服务注册(Register)是服务治理的最基本概念,内嵌了 Eureka 客户端的各个微服务通过向 Eureka 服务器提供 IP 地址、端点等各项与服务发现相关的基本信息完成服务注册操作。

因为 Eureka 客户端与服务器端通过短连接完成交互,所以在服务续约(Renew)中,Eureka 客户端需要每隔一定时间主动上报自己的运行时状态,从而进行服务续约。

服务取消(Cancel)的意思就是 Eureka 客户端主动告知 Eureka 服务器自己不想再注册到 Eureka 中。当Eureka客户端连续一段时间没有向 Eureka 服务器发送服务续约信息时,Eureka 服务器就会认为该服务实例已经不再运行,从而将其从服务列表中进行剔除(Evict)。

Eureka 服务存储源码解析

对于一个注册中心而言,我们首先需要关注它的数据存储方法。在 Eureka 中,我们发现 InstanceRegistry 接口及其实现类(位于 com.netflix.eureka.registry 包中)承接了这部分职能。InstanceRegistry 的类层结构如下所示:

从上图中,不难看出 Spring Cloud 中同样存在一个 InstanceRegistry(位于 org.springframework.cloud.netflix.eureka.server 包中),它实际上是基于 Netflix 中 InstanceRegistry 实现的一种包装。我们在上图中 InstanceRegistry 接口的实现类 AbstractInstanceRegistry 中发现了 Eureka 用于保存注册信息的数据结构,如下所示:

private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry = new ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>>();

可以看到这是一个双层的 HashMap,采用的是 JDK 中线程安全的 ConcurrentHashMap。其中第一层的 ConcurrentHashMap 的 Key 为 spring.application.name,也就是服务名,Value 为一个 ConcurrentHashMap;而第二层的 ConcurrentHashMap 的 Key 为 instanceId,也就是服务的唯一实例 ID,Value 为 Lease 对象。

Eureka 采用 Lease(租约)这个词来表示对服务注册信息的抽象,Lease 对象保存了服务实例信息以及一些实例服务注册相关的时间,如注册时间 registrationTimestamp、最新的续约时间 lastUpdateTimestamp 等。如果用图形化的表达方式来展示这种数据结构,可以参考下图:

而对于 InstanceRegistry 本身,它也继承了 Eureka 中两个非常重要的接口,即LeaseManager 接口和 LookupService 接口。其中 LeaseManager 接口定义如下:

显然 LeaseManager 做的事情就是 Eureka 注册中心模型中的服务注册、服务续约、服务取消和服务剔除等核心操作,关注于对服务注册过程的管理。而 LookupService 接口定义如下,关注于对应用程序与服务实例的管理:

在内部实现上,实际上对于注册中心服务器而言,服务注册、续约、取消和剔除等不同操作所执行的工作流程基本一致,即都是对服务存储的操作,并把这一操作同步到其他 Eureka 节点。我们这里选择用于服务注册操作的 register 方法进行展开,register 方法非常长,我们对源码进行裁剪,得出如下所示的核销处理流程:

Eureka 服务缓存源码解析

Eureka 服务器端组件的另一个核心功能是提供服务列表。为了提高性能,Eureka 服务器会缓存一份所有已注册的服务列表,并通过一定的定时机制对缓存数据进行更新。

我们知道为了获取注册到 Eureka 服务器上具体某一个服务实例的详细信息。

Eureka 中所有对服务器端的访问都是通过RESTful 风格的资源(Resource) 进行获取,ApplicationResource 类(位于com.netflix.eureka.resources 包中)提供了根据应用获取注册信息的入口。我们来看该类的 getApplication 方法,核心代码如下所示:

可以看到这里是构建了一个 cacheKey,并直接调用了 responseCache.get(cacheKey) 方法来返回一个字符串并构建响应。其中最核心的就是这里的 get 方法:

从类层关系上看,ResponseCache 只有一个实现类 ResponseCacheImpl,我们来看它的 get 方法,发现该方法使用了如下处理策略:

可以看到上述代码中有两个缓存,一个是 readOnlyCacheMap,一个是 readWriteCacheMap。其中 readOnlyCacheMap 就是一个 JDK 中的 ConcurrentMap,而 readWriteCacheMap 使用的则是 Google Guava Cache 库中的 LoadingCache 类型。在创建 LoadingCache过程中,缓存数据的来源是调用 generatePayload 方法来生成。

而在这个 generatePayload 方法中,就会调用前面介绍的 AbstractInstanceRegistry 中的 getApplications 方法获取应用信息并放到缓存中。这样我们就实现了把注册信息与缓存信息进行关联。

这里有一个设计和实现上的技巧。把缓存设计为一个只读的 readOnlyCacheMap 以及一个可读写的 readWriteCacheMap,可以更好地分离职责。但因为两个缓存中保存的实际上是同一份数据,所以,我们在不断更新 readWriteCacheMap 时,也需要确保 readOnlyCacheMap 中的数据得到同步。为此 ResponseCacheImpl 提供了一个定时任务 CacheUpdateTask,如下所示:

显然,这个定时任务主要是从 readWriteCacheMap 更新数据到 readOnlyCacheMap。

Eureka 高可用源码解析

我们已经在前面的内容中了解到 Eureka 的高可用部署方式被称为 Peer Awareness 模式。对应的,我们在 InstanceRegistry 的类层结构中也已经看到了它的一个扩展接口 PeerAwareInstanceRegistry 以及该接口的实现类 PeerAwareInstanceRegistryImpl。

我们还是围绕服务注册这个场景展开讨论,在 PeerAwareInstanceRegistryImpl 中同样存在一个 register 方法,如下所示:

我们在这里看到了一个非常重要的replicateToPeers 方法,该方法作就是用来实现服务器节点之间的状态同步。replicateToPeers 方法的核心代码如下所示:

为了理解这个操作,我们首先需要理解 Eureka 中的集群模式,这部分代码位于 com.netflix.eureka.cluster 包中,其中包含了代表节点的 PeerEurekaNode 和 PeerEurekaNodes 类,以及用于节点之间数据传递的 HttpReplicationClient 接口。而 replicateInstanceActionsToPeers 方法中则根据不同的 Action 来调用 PeerEurekaNode 的不同方法。例如,如果是 StatusUpdate Action,则会调动 PeerEurekaNode的statusUpdate 方法,而该方法又会执行如下代码。

replicationClient.statusUpdate(appName, id, newStatus, info);

这句代码完成了 PeerEurekaNode 之间的通信,而 replicationClient 是 HttpReplicationClient 接口的实例,该接口定义如下:

HttpReplicationClient 接口继承自 EurekaHttpClient 接口,而 EurekaHttpClient 接口属于 Eureka 客户端组件,我们会在下一课时介绍 Eureka 客户端基本原理时进行详细介绍。在这里,我们只需要明白 Eureka 提供了 JerseyReplicationClient(位于 com.netflix.eureka.transport 包下)这一基于 Jersey 框架实现的HttpReplicationClient。以 statusUpdate 方法为例,它的实现过程如下:

这是典型的基于 Resource 的 RESTful 风格的调用方法,用到了 ApacheHttpClient4 工具类。通过以上分析,我们已经从主要维度上掌握了整个 Eureka 服务器端内部的运行机制。

服务发现

实现服务注册

我们首先需要确保在 Maven 工程中添加对 Eureka 客户端组件 spring-cloud-starter-netflix-eureka-client 的依赖,如下所示。

然后,我们来看 user-service 的 Bootstrap 类,这里引入了一个新的注解 @EnableEurekaClient。当然,随着我们后续内容的演进,你会发现可以使用统一的 @SpringCloudApplication 注解,来实现 @SpringBootApplication 和 @EnableEurekaClient 这两个注解整合在一起的效果。

user-service 中的配置内容如下所示:

这里的 serviceUrl 配置项在上一课时中已经介绍过,serviceUrl.defaultZone 指定的就是 Eureka 服务器的地址。

当然,如果我们同样基于上一课时中介绍的 Peer Awareness 模式构建了 Eureka 服务器集群,那么 eureka.client.serviceUrl.defaultZone 配置项的内容就应该是“http://eureka1:8761/eureka/,http://eureka2:8762/eureka/”,用于指向当前的集群环境。

实现服务发现

当我们成功创建并启动了 user-service 之后,就可以在Eureka的界面发现服务已经注册了。我们可以获取该服务的服务名称、IP 地址、端口、是否可用等基本信息,也可以访问 statusPageUrl、healthCheckUrl 等地址查看当前服务的运行状态,更为重要的是得到了 leaseInfo 等与服务注册过程直接相关的基础数据,这些基础数据有助于我们理解 Eureka 作为注册中心的工作原理。

理解 Eureka 客户端基本原理

对于 Eureka 而言,微服务的提供者和消费者都是它的客户端,其中服务提供者关注服务注册、服务续约和服务下线等功能,而服务消费者关注于服务信息的获取。同时,对于服务消费者而言,为了提高服务获取的性能以及在注册中心不可用的情况下继续使用服务,一般都还会具有缓存机制。

在 Netflix Eureka 中,专门提供了一个客户端包,并抽象了一个客户端接口 EurekaClient。EurekaClient 接口继承自 LookupService 接口,这个 LookupService 接口实际上也是我们上一课时中所介绍的 InstanceRegistry 接口的父接口。EurekaClient 在 LookupService 接口的基础上提供了一系列扩展方法,这些扩展方法并不是重点,我们还是更应该关注于它的类层机构,如下所示:

可以看到 EurekaClient 接口有个实现类 DiscoveryClient(位于 com.netflix.discovery 包中),该类包含了服务提供者和服务消费者的核心处理逻辑,同时提供了我们在介绍 Eureka 服务器端基本原理时所介绍的 register、renew 等方法。DiscoveryClient 类的实现非常复杂,我们重点关注它构造方法中的这行代码:

initScheduledTasks();

通过分析该方法中的代码,我们看到系统在这里初始化了一批调度任务,具体包含缓存刷新 cacheRefresh、心跳 heartbeat、服务实例复制 InstanceInfoReplicator 等,其中缓存刷新面向服务消费者,而心跳和服务实例复制面向服务提供者。接下来我们将分别从这两个 Eureka 客户端组件出发讨论服务注册和发现的客户端操作。

服务提供者操作源码解析

服务提供者关注服务注册、服务续约和服务下线等功能,它可以使用 Eureka 服务器提供的 RESTful API 完成上述操作。因为篇幅关系,这里同样以服务注册为例给出服务提供者的操作流程。

在 DiscoveryClient 类中,服务注册操作由register 方法完成,如下所示。为了简单起见,我们对代码进行了裁剪,省略了日志相关等非核心代码:

上述 register 方法会在 InstanceInfoReplicator 类的 run 方法中进行执行。从操作流程上讲,上述代码的逻辑非常简单,即服务提供者先将自己注册到 Eureka 服务器中,然后根据返回的结果确定操作是否成功。显然,这里的重点代码是eurekaTransport.registrationClient.register(),DiscoveryClient 通过这行代码发起了远程请求。

首先我们来看 EurekaTransport 类,这是 DiscoveryClient 类中的一个内部类,定义了 registrationClient 变量用于实现服务注册。registrationClient 的类型是 EurekaHttpClient 接口,该接口的定义如下:

可以看到这个 EurekaHttpClient 接口定义了 Eureka 服务器的一些底层 REST API,包括 register、cancel、sendHeartBeat、statusUpdate、getApplications 等。在 Eureka 中,关于如何实现客户端与服务器端的远程通信,从工作原理上讲只是一个 RESTful 风格的 HTTP 请求,但在具体设计和实现上可以说是非常考究,因此类层结构上也比较复杂。我们先来看 EurekaHttpClient 接口的一个实现类 EurekaHttpClientDecorator,从命名上看它是一个装饰器(Decorator),如下所示:

可以看到 EurekaHttpClientDecorator 通过定义一个抽象方法 execute(RequestExecutor requestExecutor) 来包装 EurekaHttpClient,这种包装是代理机制的一种表现形式。

然后我们再来看如何构建一个 EurekaHttpClient,Eureka 也专门提供了 EurekaHttpClientFactory 类来负责构建具体的 EurekaHttpClient。显然,这是工厂模式的一种典型应用。EurekaHttpClientFactory 接口定义如下:

Eureka 中存在一批 EurekaHttpClientFactory 的实现类,包括 RetryableEurekaHttpClient 和 MetricsCollectingEurekaHttpClient 等,这些类都位于 com.netflix.discovery.shared.transport.decorator 包下。同时,在 com.netflix.discovery.shared.transport 包下,还存在一个 EurekaHttpClients 工具类,能够创建通过 RedirectingEurekaHttpClient、RetryableEurekaHttpClient、SessionedEurekaHttpClient 包装之后的 EurekaHttpClient。如下所示:

这是 EurekaHttpClient 创建过程中的一条分支,即通过包装器对请求过程进行层层封装和代理。而在执行远程请求时,Eureka 同样提供了另一套体系来完成真正的远程调用,原始的 EurekaHttpClient 通过 TransportClientFactory 进行创建。TransportClientFactory 接口定义如下:

TransportClientFactory 同样存在一批实现类,其中有些是实名类,有些是匿名类。以实名的实现类 JerseyEurekaHttpClientFactory 为例,它位于 com.netflix.discovery.shared.transport.jersey 包下,通过 EurekaJerseyClient 获取 Jersey 客户端,而 EurekaJerseyClient 又会使用 ApacheHttpClient4 对象,从而完成 REST 调用。

作为总结,这里也给你分享一个 Eureka 在设计和实现上的技巧,也就是所谓的高阶(High Level)API和低阶(Low Level)API,如下图所示:

针对高阶 API,主要是通过装饰器模式进行一系列包装,从而创建目标 EurekaHttpClient。而关于低阶 API 的话,主要是 HTTP 远程调用的实现,Netflix 提供的是基于 Jersey 的版本,而 Spring Cloud 则提供了基于 RestTemplate 的版本,这点我们后面会再讲到。

服务消费者操作源码解析

我们在介绍注册中心模型时,服务消费者可以配备缓存机制以加速服务路由。对于 Eureka 而言,作为客户端组件的 DiscoveryClient 同样具备这种缓存功能。

Eureka 客户端通过定时任务完成缓存刷新操作,我们已经在前面的内容中提到 DiscoveryClient 中的 initScheduledTasks 方法用于初始化各种调度任务,对于缓存刷选而言,调度器的初始化过程如下所示:

对于服务消费者而言,最重要的操作就是获取服务注册信息。在这里的 refreshRegistry 方法中,我们发现在进行一系列的校验之后,最终调用了 fetchRegistry 方法以完成注册信息的更新,该方法代码如下。为了简单起见,我们对代码进行了部分裁剪,只保留主流程:

这里的几个带注释的方法都非常有用,因为 getAndStoreFullRegistry 的逻辑相对比较简单,我们将重点介绍 getAndUpdateDelta 方法,以便学习在 Eureka 中如何实现增量数据更新的设计技巧。裁剪之后的 getAndUpdateDelta 方法代码如下所示:

回顾 Eureka 服务器端基本原理,我们知道 Eureka 服务器端会保存一个服务注册列表的缓存。

Eureka 官方文档中提到这个数据保留时间是三分钟,而 Eureka 客户端的定时调度机制会每隔 30 秒刷选本地缓存。原则上,只要 Eureka 客户端不停地获取服务器端的更新数据,就能保证自己的数据和 Eureka 服务器端的保持一致。但如果客户端在 3 分钟之内没有获取更新数据,就会导致自身与服务器端的数据不一致,这是这种更新机制所必须要考虑的问题,也是我们自己在设计类似场景时的一个注意点。

针对上述问题,Eureka 采用了一致性 HashCode 方法来进行解决。Eureka 服务器端每次返回的增量数据中都会带有一个一致性 HashCode,这个 HashCode 会与 Eureka 客户端用本地服务列表数据算出的一致性 HashCode 进行比对,如果两者不一致就证明增量更新出了问题,这时候就需要执行一次全量更新。

在 Eureka 中,计算一致性 HashCode 的方法如下所示,可以看到这一方法基于服务注册实例信息完成编码计算过程,最终返回一个 String 类型的计算结果:

作为总结,Eureka 客户端缓存定时更新的流程如下图所示,可以看到它与服务注册的流程基本一致,也就是说在 Eureka 中,服务提供者和服务消费者作为 Eureka 服务器的客户端采用了同一套体系完成与服务器端的交互。

负载均衡

Spring Cloud 中同样存在着与 Eureka 配套的负载均衡器,这就是 Ribbon 组件。Eureka 和 Ribbon 的交互方式如下图所示:

今天,我们就将结合上图详细介绍如何使用 Ribbon 来实现负载均衡的使用方法。Ribbon 的定位是一款用于提供客户端负载均衡的工具软件。Ribbon 会自动地基于某种内置的负载均衡算法去连接服务实例,我们也可以设计并实现自定义的负载均衡算法并嵌入 Ribbon 中。同时,Ribbon 客户端组件提供了一系列完善的辅助机制用来确保服务调用过程的可靠性和容错性,包括连接超时和重试等。Ribbon 是客户端负载均衡机制的典型实现方案,所以需要嵌入在服务消费者的内部进行使用。

Ribbon 的核心功能

1.使用 @LoadBalanced 注解。

@LoadBalanced 注解用于修饰发起 HTTP 请求的 RestTemplate 工具类,并在该工具类中自动嵌入客户端负载均衡功能。开发人员不需要针对负载均衡做任何特殊的开发或配置。

2.使用 @RibbonClient 注解。

Ribbon 还允许你使用 @RibbonClient 注解来完全控制客户端负载均衡行为。这在需要定制化负载均衡算法等某些特定场景下非常有用,我们可以使用这个功能实现更细粒度的负载均衡配置。

使用 DiscoveryClient 获取服务实例信息

接下来,让我们来演示如何根据服务名称获取 Eureka 中的服务实例信息。通过 DiscoveryClient 可以很容易实现这一点。

首先,我们获取当前注册到 Eureka 中的服务名称全量列表,如下所示:

List<String> serviceNames = discoveryClient.getServices();

基于这个服务名称列表可以获取所有自己感兴趣的服务,并进一步获取这些服务的实例信息:

List<ServiceInstance> serviceInstances = discoveryClient.getInstances(serviceName);

ServiceInstance 对象代表服务实例,包含了很多有用的信息,定义如下:

显然,一旦获取了一个 ServiceInstance 列表,我们就可以基于常见的随机、轮询等算法来实现客户端负载均衡,也可以基于服务的 URI 信息等实现各种定制化的路由机制。一旦确定负载均衡的最终目标服务,就可以使用 HTTP 工具类来根据服务的地址信息发起远程调用。

在 Spring 的世界中,访问 HTTP 端点最常见的方法就是使用 RestTemplate 工具类,让我们一起来做一些回顾。在演示 RestTemplate 的使用方法之前,我们先在 SpringHealth 案例的 user-service 添加一个 HTTP 端点,如下所示:

然后,我们构建一个测试类来访问这个 HTTP 端点。如果我们能够获取注册中心中的服务定义,我们就可以通过 ServiceInstance 对该服务进行调用,如下所示:

可以看到,这里通过 RestTemplate 工具类就可以使用 ServiceInstance 中的 URL 轻松实现 HTTP 请求。在上面的示例代码中,我们通过 instances.get(0) 方法获取的是服务列表中的第一个服务,然后使用 RestTemplate 的 exchange() 方法封装整个 HTTP 请求调用过程并获取结果。

通过 @Loadbalanced 注解调用服务

如果你掌握了 RestTemplate 的使用方法,那么在 Spring Cloud 中基于 Ribbon 来实现负载均衡非常简单,要做的事情就是在 RestTemplate 上添加一个注解,仅此而已。

接下来,我们继续使用前面介绍的 user-service 进行演示。因为涉及负载均衡,所以我们首先需要运行至少两个 user-service 服务实例。另一方面,为了显示负载均衡环境下的调用结果,我们在 UserController 中添加日志方便在运行时观察控制台输出信息。重构后的 UserController 的代码如下所示。

我们知道 intervention-service 会访问 user-service 以便生成健康干预信息。对于 user-service 而言,intervention-service 就是它的客户端。我们在 intervention-service 的启动类 InterventionApplication中,通过 @LoadBalanced 注解创建 RestTemplate。现在的 InterventionApplication 类代码如下所示:

对于 intervention-service 而言准备工作已经就绪,现在就可以编写访问 user-service 的远程调用代码。我们在 intervention-service 工程中添加一个新的 UserServiceClient 类并添加以下代码:

可以看到以上代码就是注入 RestTemplate,然后通过 RestTemplate 的 exchange() 方法对 user-service 进行远程调用。但是请注意,这里的 RestTemplate 已经具备了客户端负载均衡功能,因为我们在 InterventionApplication 类中创建该 RestTemplate 时添加了 @LoadBalanced 注解。同样请注意,URL“http://userservice/users/{userName}”中的”userservice”是在 user-service 中配置的服务名称,也就是在注册中心中存在的名称。至于这里的 UserMapper 类,只是一个数据传输对象,用于完成序列化操作。

通过 @RibbonClient 注解自定义负载均衡策略

在前面的演示中,我们完全没有感觉到 Ribbon 组件的存在。在基于 @LoadBalanced 注解执行负载均衡时,采用的是 Ribbon 内置的负载均衡机制。默认情况下,Ribbon 使用的是轮询策略,我们无法控制具体生效的是哪种负载均衡算法。但在有些场景下,我们就需要对负载均衡这一过程进行更加精细化的控制,这时候就可以用到 @RibbonClient 注解。

通常,我们需要指定这里的目标服务名称以及负载均衡配置类。所以,为了使用 @RibbonClient 注解,我们需要创建一个独立的配置类,用来指定具体的负载均衡规则。以下代码演示的就是一个自定义的配置类 SpringHealthLoadBalanceConfig:

显然该配置类的作用是使用 RandomRule 替换 Ribbon 中的默认负载均衡策略 RoundRobin。我们可以根据需要返回任何自定义的 IRule 接口的实现策略,关于 IRule 接口的定义放在下一课时进行讨论。

有了这个 SpringHealthLoadBalanceConfig 之后,我们就可以在调用特定服务时使用该配置类,从而对客户端负载均衡实现细粒度的控制。在 intervention-service 中使用 SpringHealthLoadBalanceConfig 实现对 user-service 访问的示例代码如下所示:

可以注意到,我们在 @RibbonClient 中设置了目标服务名称为 userservice,配置类为 SpringHealthLoadBalanceConfig。现在每次访问 user-service 时将使用 RandomRule 这一随机负载均衡策略。

对比 @LoadBalanced 注解和 @RibbonClient 注解,如果使用的是普通的负载均衡场景,那么通常只需要 @LoadBalanced 注解就能完成客户端负载均衡。而如果我们要对 Ribbon 运行时行为进行定制化处理时,就可以使用 @RibbonClient 注解。

Netflix Ribbon 基本架构

作为一款客户端负载均衡工具,要做的事情无非就是两件:第一件事情是获取注册中心中的服务器列表;第二件事情是在这个服务列表中选择一个服务进行调用。

Netflix Ribbon 中的核心类

Netflix Ribbon 的核心接口 ILoadBalancer 就是围绕着上述两个问题来设计的,该接口位于 com.netflix.loadbalancer 包下,定义如下:

ILoadBalancer 接口的类层结构如下所示:

其中 AbstractLoadBalancer 是个抽象类,只定义了两个抽象方法,并不构成一种模板方法的结构。所以我们直接来看 ILoadBalancer 接口,该接口最基本的实现类是 BaseLoadBalancer,可以说负载均衡的核心功能都可以在这个类中得以实现。这个类代码非常多且杂,我们在理解上需要对其进行裁剪,从而抓住重点。

我们先来梳理 BaseLoadBalancer 包含的作为一个负载均衡器应该具备的一些核心组件,比较重要的有以下三个。

1.IRule

IRule 接口是对负载均衡策略的一种抽象,可以通过实现这个接口来提供各种适用的负载均衡算法,我们在上一课时介绍 @RibbonClient 注解时已经看到过这个接口。该接口定义如下:

显然 choose 方法是该接口的核心方法,我们在下文中会基于该方法对各种负载均衡算法进行具体展开。

2. IPing

IPing 接口判断目标服务是否存活,定义如下:

可以看到 IPing 接口中只有一个 isAlive() 方法,通过对服务发出"Ping"操作来获取服务响应,从而判断该服务是否可用。

3.LoadBalancerStats

LoadBalancerStats 类记录负载均衡的实时运行信息,用来作为负载均衡策略的运行时输入。

注意,在 BaseLoadBalancer 内部维护着 allServerList 和 upServerList 这两个线程的安全列表,所以对于 ILoadBalancer 接口定义的 addServers、getReachableServers、getAllServers 这几个方法而言,主要就是对这些列表的维护和管理工作。以 addServers 方法为例,它的实现如下所示:

显然,这里的处理过程就是将原有的服务实例列表 allServerList 和新传入的服务实例列表 newServers 都合并到一个 newList 中,然后再调用 setServersList 方法用这个新的列表覆盖旧的列表。

针对负载均衡,我们重点应该关注的是 ILoadBalancer 接口中 chooseServer 方法的实现,不难想象该方法肯定通过前面介绍的 IRule 接口集成了具体负载均衡策略的实现。在 BaseLoadBalancer 中的 chooseServer 方法如下所示:

果然,这里使用了 IRule 接口的 choose 方法。接下来就让我们看看 Ribbon 中的 IRule 接口为我们提供了具体哪些负载均衡算法。

Netflix Ribbon 中的负载均衡策略

一般而言,负载均衡算法可以分成两大类,即静态负载均衡算法和动态负载均衡算法。静态负载均衡算法比较容易理解和实现,典型的包括随机(Random)、轮询(Round Robin)和加权轮询(Weighted Round Robin)算法等。所有涉及权重的静态算法都可以转变为动态算法,因为权重可以在运行过程中动态更新。例如动态轮询算法中权重值基于对各个服务器的持续监控并不断更新。另外,基于服务器的实时性能分析分配连接是常见的动态策略。典型动态算法包括源 IP 哈希算法、最少连接数算法、服务调用时延算法等。

回到 Netflix Ribbon,IRule 接口的类层结构如下图所示:

可以看到 Netflix Ribbon 中的负载均衡实现策略非常丰富,既提供了 RandomRule、RoundRobinRule 等无状态的静态策略,又实现了 AvailabilityFilteringRule、WeightedResponseTimeRule 等多种基于服务器运行状况进行实时路由的动态策略。

在上图中还看到了 RetryRule 这种重试策略,该策略会对选定的负载均衡策略执行重试机制。严格意义上讲重试是一种服务容错而不是负载均衡机制,但 Ribbon 也内置了这方面的功能。

静态的几种策略相对都比较简单,而像 RetryRule 实际上不算是严格意义上的负载均衡策略,所以这里重点关注 Ribbon 所实现的几种不同的动态策略。

1.BestAvailableRule 策略

选择一个并发请求量最小的服务器,逐个考察服务器然后选择其中活跃请求数最小的服务器。

2.WeightedResponseTimeRule 策略

该策略与请求的响应时间有关,显然,如果响应时间越长,就代表这个服务的响应能力越有限,那么分配给该服务的权重就应该越小。而响应时间的计算就依赖于前面介绍的 ILoadBalancer 接口中的 LoadBalancerStats。WeightedResponseTimeRule 会定时从 LoadBalancerStats 读取平均响应时间,为每个服务更新权重。权重的计算也比较简单,即每次请求的响应时间减去每个服务自己平均的响应时间就是该服务的权重。

3.AvailabilityFilteringRule 策略

通过检查 LoadBalancerStats 中记录的各个服务器的运行状态,过滤掉那些处于一直连接失败或处于高并发状态的后端服务器。

Spring Cloud Netflix Ribbon

而 Spring Cloud 中的 Spring Cloud Netflix Ribbon 就是就专门针对 Netflix Ribbon 提供了一个独立的集成实现。

Spring Cloud Netflix Ribbon 相当于 Netflix Ribbon 的客户端。而对于 Spring Cloud Netflix Ribbon 而言,我们的应用服务相当于它的客户端。Netflix Ribbon、Spring Cloud Netflix Ribbon、应用服务这三者之间的关系以及核心入口如下所示:

这次,我们打算从应用服务层的 @LoadBalanced 注解入手,切入 Spring Cloud Netflix Ribbon,然后再从 Spring Cloud Netflix Ribbon 串联到 Netflix Ribbon,从而形成整个负载均衡闭环管理。

@LoadBalanced 注解

使用过 Spring Cloud Netflix Ribbon 的同学可能会问,为什么通过 @LoadBalanced 注解创建的 RestTemplate 就能自动具备客户端负载均衡的能力?这也是一个面试过程中经常被问到的问题。

事实上,在 Spring Cloud Netflix Ribbon 中存在一个自动配置类——LoadBalancerAutoConfiguration 类。而在该类中,维护着一个被 @LoadBalanced 修饰的 RestTemplate 对象的列表。在初始化的过程中,对于所有被 @LoadBalanced 注解修饰的 RestTemplate,调用 RestTemplateCustomizer 的 customize 方法进行定制化,该定制化的过程就是对目标 RestTemplate 增加拦截器 LoadBalancerInterceptor,如下所示:

这个 LoadBalancerInterceptor 用于实时拦截,可以看到它的构造函数中传入了一个对象 LoadBalancerClient,而在它的拦截方法本质上就是使用 LoadBalanceClient 来执行真正的负载均衡。LoadBalancerInterceptor 类代码如下所示:

可以看到这里的拦截方法 intercept 直接调用了 LoadBalancerClient 的 execute 方法完成对请求的负载均衡执行。

LoadBalanceClient 接口

LoadBalancerClient 是一个非常重要的接口,定义如下:

这里有两个 execute 重载方法,用于根据负载均衡器所确定的服务实例来执行服务调用。而 reconstructURI 方法则用于构建服务 URI,使用负载均衡所选择的 ServiceInstance 信息重新构造访问 URI,也就是用服务实例的 host 和 port 再加上服务的端点路径来构造一个真正可供访问的服务。

LoadBalancerClient 继承自 ServiceInstanceChooser 接口,该接口定义如下:

从负载均衡角度讲,我们应该重点关注实际上是这个 choose 方法的实现,而提供具体实现的是实现了 LoadBalancerClient 接口的 RibbonLoadBalancerClient,而 RibbonLoadBalancerClient 位于 spring-cloud-netflix-ribbon 工程中。这样我们的代码流程就从应用程序转入到了 Spring Cloud Netflix Ribbon 中。

在 LoadBalancerClient 接口的实现类 RibbonLoadBalancerClient 中,choose 方法最终调用了如下所示的 getServer 方法:

这里的 loadBalancer 对象就是前面介绍的 Netflix Ribbon 中的 ILoadBalancer 接口的实现类。这样,我们就把 Spring Cloud Netflix Ribbon 与 Netflix Ribbon 的整体协作流程串联起来。

更多资料,私信回复【1】......

今日份分享已结束,请大家多多包涵和指点!

最近发表
标签列表