后续文章:
7、7 集中式日志和分布式跟踪
8、8 容器化微服务
通过消息代理可实现事件驱动架构的开发,使用RabbitMQ能够解决消费端处理事件的松耦合,实现消费端的负载均衡,便于扩展系统,提供弹性解决方案,但前端和微服务之间的负载均衡问题还没有解决。
网关模式可以解决一些问题:
这些问题可通过网关微服务来解决。网关模式可集中HTTP访问,负责将请求代理到其他服务。网关会根据配置的规则来决定将请求路由到何处,另外,路由服务可以过滤器来修改请求和响应通过时的状态。如图所示:
有时会将网关称为边缘服务,因为其他系统必须通过网关来访问后端,且网关将外部流量路由到相应的内部微服务,网关的引入通常会限制对其他后端服务的访问。
Spring Cloud是Spring系列中的一组独立项目,提供技术来快速构建分布式系统(如微服务等)所需的通用模式。这些模式称为云模式,即使在服务器中部署微服务,也适用。它利用 Spring Boot 的开发便利性简化了分布式系统的开发,比如服务发现、服务网关、服务路由、链路追踪等。Spring Cloud不重复造轮子,而是将市面上开发得比较好的模块集成进去,进行封装,从而减少了各模块的开发成本。换句话说:Spring Cloud 提供了构建分布式系统所需的“全家桶”。
网关模式是Spring Cloud提供的一个工具,Spring长期使用Spring Cloud Netflix支持集成,它由Netflix开发人员作为开源软件发行并维护了许多年了,Netflix支持网关模式的组件是Zuul,与Spring集成通过Spring Cloud Netflix Zuul模块实现。
这里不使用Spring Cloud Netflix,原因是Spring将不再使用Netflix工具集成,而是用其他工具替代,甚至是自己实现,可能是Netflix将某些项目置于维护模式,如Hystrix(断路)和Ribbon(负载均衡),不再主动开发,这还会影响Netflix堆栈中的其他工具,如服务发现工具Eureka依靠Ribbon进行负载均衡。
这里将需求一种新的方案实现网关模式,Spring Cloud网关,不依赖于任何外部工具。
Spring Cloud网关项目定义了一些核心概念(如图所示):
要使用Spring Cloud网关,需要定义相关的配置,主要是谓词、路由和过滤器,可以在属性文件或yaml文件中配置。示例如下:
spring:
cloud:
gateway:
routes:
- id: old-path
uri: http://oldhost/path-old
predicates:
- Before=2023-12-10T09:10:57.013+08:00
- Path=/path-special/**
- id: new-path
uri: http://newhost/path-new
predicates:
- Path=/path-special/**
- After=2023-12-10T09:10:57.013+08:00
filters:
- AddResponseHeader=X-New-Conditions-Apply, 2023-Dec
这里定义了两个共享路径路由谓词,该谓词会捕获任何访问/path-special/开头的请求,每个路由的附加条件分别由Before和After谓词定义,决定了在何处代理请求。
这里的oldhost和newhost不需要从外部访问,仅对后端的网关和其他内部服务可见。
内置的谓词和过滤器可以满足网关的各种需求,非常方便,在示例中将主要使用路径路由谓词,将外部请求代理到相应的微服务。
Spring Boot为Spring Cloud Gateway提供了启动器(spring-boot-starter-*),将此启动器依赖添加到Spring Boot应用程序中,即可获得可用的Gateway微服务。实际上,Gateway项目是建立在Spring Boot之上的,只能在Spring Boot应用程序中使用。这里,自动配置类GatewayAutoConfiguration位于Spring Boot Gateway中,而不是Spring Boot的autoconfigure包中,它读取配置信息并构建路由、过滤器、谓词等。
在IDEA中创建新的微服务gateway,添加Gateway依赖项,如图所示:
打开项目的maven配置文件pom.xml,如下所示:
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>cn.zhangjuli</groupId>
<artifactId>gateway</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gateway</name>
<description>gateway</description>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
下面将application.properties改名为application.yml,添加配置信息,如下所示:
server:
port: 8000
spring:
cloud:
gateway:
routes:
- id: multiplication
uri: http://localhost:8080/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: http://localhost:8081/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
这里配置的路由将使网关按如下方式运行:
也可用编写一个更简单的配置,不需要路由到每个微服务的显式端点列表,可用使用网关的另一个功能来完成此操作,该功能允许捕获路径段。如果获得了诸如http://localhost:8000/multiplication/attempts的API调用,就可用将Multiplication提取出来,然后,映射到相应的主机和端口,这种情况,当微服务只包含一个API域时才有效。其他情况下,都会将内部架构公开给客户端。示例中,当客户端发出http://localhost:8000/multiplication/users请求时,应该使用一个http://localhost:8000/users代替,以便隐藏Multiplication微服务中包含User域的事实,提高安全性。
引入gateway微服务后,可将用于外部请求的所有配置保留在同一服务内,这意味着不需要向Multiplication和Gamification微服务中添加CORS配置。可在网关中保留该配置,因为其他两个服务位于新的代理服务之后。因此,可以从现有项目Multiplication和Gamification的文件夹configuration中删除WebConfiguration类。
删除了CORS配置类后,还需要修改React应用程序使其指向两个微服务的相同主机/端口。还可以根据偏好来重构GameApiClient和ChallengesApiClient类,单个微服务调用所有端点或每个API上下文(挑战、用户等)调用一个服务,不再需要两个不同的服务器URL,因为前端现在将后端视为具有多个API的单个主机。例如:
class GameApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_LEADERBOARD = '/leaders';
static leaderBoard(): Promise<Response> {
return fetch(GameApiClient.SERVER_URL + GameApiClient.GET_LEADERBOARD);
}
}
export default GameApiClient;
class ChallengesApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
static GET_ATTEMPTS_BY_ALIAS = '/attempts?alias=';
static GET_USERS_BY_IDS = '/users';
static challenge(): Promise<Response> {
return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.GET_CHALLENGE);
}
static sendGuess(user: string,
a: number,
b: number,
guess: number): Promise<Response> {
return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.POST_RESULT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userAlias: user,
factorA: a,
factorB: b,
guess: guess
})
});
}
static getAttempts(userAlias: string): Promise<Response> {
console.log('Get attempts for ' + userAlias)
return fetch(ChallengesApiClient.SERVER_URL + ChallengesApiClient.GET_ATTEMPTS_BY_ALIAS + userAlias);
}
static getUsers(userIds: number[]): Promise<Response> {
return fetch(ChallengesApiClient.SERVER_URL +
ChallengesApiClient.GET_USERS_BY_IDS +
'/' + userIds.join(','));
}
}
export default ChallengesApiClient;
要执行整个系统,需要启动各个微服务,运行各个Spring Boot应用程序:
这里不需要按照顺序启动这些微服务,在启动过程中,系统可能不稳定,但最终会准备就绪。
当访问前端时,不会注意到任何改变,排行榜已经加载,可以正常发送尝试。验证请求是否被代理,可以查看浏览器开发人员工具中的网络选项卡,选择一个后端请求,以http://localhost:8000开头;也可以向网关中添加跟踪日志配置,就可以看到正在执行的操作了,在application.yml中进行配置,例如:
# 配置路由跟踪日志
logging:
level:
org.springframework.cloud.gateway.handler.predicate: trace
重新启动网关,就会看到每个请求的日志,如下所示:
2023-12-14T12:07:12.333+08:00 TRACE 50552 --- [ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory : Pattern "[/challenges/**, /attempts, /attempts/**, /users/**]" does not match against value "/leaders"
2023-12-14T12:07:12.334+08:00 TRACE 50552 --- [ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory : Pattern "/leaders" matches against value "/leaders"
2023-12-14T12:07:12.445+08:00 TRACE 50552 --- [ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory : Pattern "[/challenges/**, /attempts, /attempts/**, /users/**]" does not match against value "/leaders"
2023-12-14T12:07:12.446+08:00 TRACE 50552 --- [ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory : Pattern "/leaders" matches against value "/leaders"
2023-12-14T12:07:12.448+08:00 TRACE 50552 --- [ctor-http-nio-3] o.s.c.g.h.p.PathRoutePredicateFactory : Pattern "/users/**" matches against value "/users/1,3"
通过网关微服务,获得了一些优势:
下面将引入负载均衡,以便可以在每个服务的所有实例之间分配流量,增加系统的可扩展性和冗余性,这需要一些先决条件:
满足第一个条件,需要引入服务发现模式,并使用一个公共注册表,不同的分布式组件可以访问该列表以了解可用服务。
对第二个条件,可使用Spring Boot Actuator将微服务公开,并指示是否正常,以便其他组件知道,这也是服务发现需要的。
在生产环境中运行的系统永远无法避免出错,可能由于代码错误导致内存不足,网络连接可能失败,微服务实例可能崩溃等。要想构建一个弹性系统,就需要引入冗余机制(同一微服务存在多个副本)来防范这些错误,以将影响降到最低。
如何知道微服务无法工作呢?如果要公开一个接口(如REST API或RabbitMQ),可使用探针与样本进行交互,看看是否作出反应。但是,使用探针时应该小心,因为想涵盖所有可能使微服务不健康(不正常)的情况。与其通过泄露逻辑来确定服务是否正常工作,不如提供一个标准的、简单的探测接口来告诉调用者服务是否正常。由服务逻辑来决定何时转换到不支持状态,具体取决于服务使用的接口的可用性、服务自身的可用性以及错误的严重性。如果该服务无法提供响应,则调用者认为它不正常。如图所示:
许多工具和框架都需要这种简单的接口约定来确定服务的运行状况,例如,负载均衡器可暂时停止将流量转移到不响应运行状况探测或响应为未就绪状态的实例,如果实例不正常,则服务发现工具可能将其从注册表中删除,如果服务在配置时间段内运行不正常,则Kubernetes等容器平台可决定重新启动服务。
Spring Boot提供了一种开箱即用的解决方案,Spring Boot Actuator,来报告微服务的运行状况,这不是Actuator的唯一功能,它还可以公开其他端点,以访问有关应用程序的不同数据,如配置的记录器、HTTP跟踪、审核事件等,甚至可以打开一个管理端点,来关闭应用程序。
Actuator端点可以独立启用或禁用,不仅可以通过Web接口使用,还可以通过Java管理扩展(Java Management Extensions,JMX)使用。这里重点介绍用作REST API端点的Web接口,默认配置仅公开两个端点:info和health。info旨在提供应用程序的常规信息,health使用运行状况指示器来输出应用程序的状态,有多个内置的运行状况指示器,可以影响应用程序的总体运行状况。这些指示器有许多是针对某些工具的,只有在应用程序中使用这些工具时,才可用,使用Spring Boot可使用自动配置进行控制。
Spring Boot Actuator工件中包含RabbitHealthIndicator类,使用RabbitTemplate对象,可以访问RabbitMQ服务器,与RabbitMQ服务器进行交互,进行运行状况检查。RabbitHealthIndicator 类代码如下:
public class RabbitHealthIndicator extends AbstractHealthIndicator {
private final RabbitTemplate rabbitTemplate;
public RabbitHealthIndicator(RabbitTemplate rabbitTemplate) {
super("Rabbit health check failed");
Assert.notNull(rabbitTemplate, "RabbitTemplate must not be null");
this.rabbitTemplate = rabbitTemplate;
}
protected void doHealthCheck(Health.Builder builder) throws Exception {
builder.up().withDetail("version", this.getVersion());
}
private String getVersion() {
return (String)this.rabbitTemplate.execute((channel) -> {
return channel.getConnection().getServerProperties().get("version").toString();
});
}
}
如果使用RabbitMQ,会在上下文中自动插入该指示器。这有助于改善整体运行状况。包含在工件spring-boot-actuator-autoconfigure中的RabbitHealthContributorAutoConfiguration类负责解决这个问题。这个配置取决于是否存在RabbitTemplate Bean,如果存在则意味着正在使用RabbitMQ模块,这会创建一个HealthContributor Bean,由整体运行状况自动配置进行检测和汇总。RabbitHealthContributorAutoConfiguration类代码如下:
@AutoConfiguration(
after = {RabbitAutoConfiguration.class}
)
@ConditionalOnClass({RabbitTemplate.class})
@ConditionalOnBean({RabbitTemplate.class})
@ConditionalOnEnabledHealthIndicator("rabbit")
public class RabbitHealthContributorAutoConfiguration extends CompositeHealthContributorConfiguration<RabbitHealthIndicator, RabbitTemplate> {
public RabbitHealthContributorAutoConfiguration() {
super(RabbitHealthIndicator::new);
}
@Bean
@ConditionalOnMissingBean(
name = {"rabbitHealthIndicator", "rabbitHealthContributor"}
)
public HealthContributor rabbitHealthContributor(Map<String, RabbitTemplate> rabbitTemplates) {
return (HealthContributor)this.createContributor(rabbitTemplates);
}
}
那么,Actuator到底这么用呢?
在pom.xml文件中添加依赖项spring-boot-starter-actuator,就可以获得Actuator功能,代码如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
需要将该依赖添加到Multiplication、Gamification和Gateway微服务中。默认配置在/actuator上下文中公开health和info端点,这就够了,而且如果需要,可通过属性进行调整。重启应用程序就可以使用该功能了,可通过浏览器或命令行,验证服务器的运行状况。
注意:不是通过网关公开/health端点,因为这不是要对外公开的功能,只是系统内部功能。
测试Gamification的Actuator,可在命令行运行如下命令,结果如下:
> http :8081/actuator/health
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/vnd.spring-boot.actuator.v3+json
Date: Fri, 15 Dec 2023 01:21:22 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
{
"status": "UP"
}
如果系统正常运行,将获得UP值和HTTP状态码200。如果停止RabbitMQ服务器,再尝试相同的请求,由于Actuator包含了一个检查了RabbitMQ服务器的运行状况指示器,该指示器会失败,就导致聚合的运行状况切换为DOWN。在RabbitMQ服务器停止的情况下发出请求,结果确实是这样的,结果如下:
> http :8081/actuator/health
HTTP/1.1 503
Connection: close
Content-Type: application/vnd.spring-boot.actuator.v3+json
Date: Fri, 15 Dec 2023 01:22:21 GMT
Transfer-Encoding: chunked
{
"status": "DOWN"
}
这时返回的HTTP状态码是503,即服务不可用。调用者不需要解析响应主体,只需要检查HTTP响应状态码是否为200即可确定该应用程序是否正常。还可以在控制台日志中看到RabbitHealthIndicator未能成功,例如:
2023-12-15T09:22:18.897+08:00 INFO 15900 --- [ntContainer#0-2] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672]
2023-12-15T09:22:21.506+08:00 INFO 15900 --- [nio-8081-exec-8] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672]
2023-12-15T09:22:21.510+08:00 WARN 15900 --- [nio-8081-exec-8] o.s.b.a.amqp.RabbitHealthIndicator : Rabbit health check failed
org.springframework.amqp.AmqpIOException: java.io.IOException
at org.springframework.amqp.rabbit.support.RabbitExceptionTranslator.convertRabbitAccessException(RabbitExceptionTranslator.java:70) ~[spring-rabbit-3.1.0.jar:3.1.0]
at
... omitted
如果重启RabbitMQ服务器,并再次检查运行状况,就会切换到UP状态。这就是健壮系统需要的行为:微服务有问题时,应该标记,让其他微服务知道;而且,应尝试从错误中恢复并在可能的情况下再次切换到再次状态。
已经能知道服务是否可用了,就可以在系统中集成服务发现和负载均衡了。
服务发现包含两个主要概念:
服务发现就是服务实例请求注册中心获取所依赖服务信息。服务实例通过注册中心,获取到注册到其中的服务实例的信息,通过这些信息去请求它们提供的服务。
从图中可以看到,有三个服务注册。host1在端口8080上部署了一个Multiplication实例,在端口8081上部署了一个Gamification实例,host2在端口9080上部署了一个Multiplication实例,这些实例都知道它们所在的位置,然后,这些注册者将相应的URI发送到服务注册中心,服务注册中心客户端可以使用其名称简单地请求服务的位置,接着,服务注册中心返回实例及其位置的列表。
负载均衡模式与服务发现紧密相关,如果多个服务在注册时使用相同的名称,则意味着有多个副本可用。想要平衡它们之间的流量,就可以增加系统的容量并通过增加的冗余使其在出现错误时更具弹性。
其他服务可从注册表中查询给定服务的名称,检索列表,然后确定要调用的实例,这称为客户端发现,表示客户端知道服务注册表并自行执行负载均衡。这里的客户端,指的是想要对另一个服务执行HTTP调用的应用程序、微服务、浏览器等。如图所示:
另一方面,服务器端发现通过提供一个预先已知的唯一地址,来从客户端抽象所有这些逻辑,调用者可在该地址中找到给定的服务。当发出请求时,负载均衡器会拦截请求,然后将请求代理到其中一个副本。如图所示:
通常,在微服务架构中,会看到两种方法结合在一起使用,或者仅使用服务器端发现。当API客户端位于系统之外时,客户端发现机制无法正常工作,因为不应该要求外部客户端与服务注册表进行交互并自行进行负载均衡。一般由网关承担此职责,因此,API网关将连接的服务注册表,并包含一个负载均衡器,用于在实例之间分配负载。
对后端内部的其他任何服务到服务通信,可将它们全部都连接到注册表进行客户端发现,也可以使用负载均衡器将每个服务集群抽象为唯一地址。后者是诸如Kubernetes的一些平台中选择的技术,该平台中,每个服务都被分配一个唯一的地址,而不管有多少副本,也不管它们位于哪个节点。
微服务没有相互调用,就可用简单实现客户端发现。Spring Boot具有连接到服务注册表并实现负载均衡的集成。
到后端的任何非内部HTTP通信都将使用服务器端发现方法,这意味着网关不仅会路由流量,还会负载均衡。如图所示。
Consul、Eureka、Zookeeper等工具实现了服务发现模式,还有一些完整的平台将这种模式作为其功能。在Spring生态系统中,Netflix的Eureka长期以来一直是最受欢迎的选择,但是由于Netflix的原因,不再是好的选择。这里将所有Consul,该工具可以提供服务发现和其他功能,并通过Spring Cloud进行集成,方便使用。
首先安装Consul,这里使用Docker下的Consul,来进行单节点部署。命令如下:
docker run -id --name=consul -p 8300:8300 -p 8301:8301 -p 8302:8302 -p 8500:8500 -p 8600:8600 consul agent -server -ui -node=n1 -bootstrap-expect=1 -client=0.0.0.0
这里暴露了几个端口,它们的作用如下:
在Spring Cloud模块集成Consul服务发现时,需要配置8500端口。除此之外,还有几个参数:
安装后,打开控制台日志,可以看到如下信息:
2023-12-15 11:57:36 ==> Starting Consul agent...
2023-12-15 11:57:36 Version: '1.11.1'
2023-12-15 11:57:36 Node ID: 'ecf41197-fa98-7cc6-a2c3-295ad75c7efd'
2023-12-15 11:57:36 Node name: 'n1'
2023-12-15 11:57:36 Datacenter: 'dc1' (Segment: '<all>')
2023-12-15 11:57:36 Server: true (Bootstrap: true)
2023-12-15 11:57:36 Client Addr: [0.0.0.0] (HTTP: 8500, HTTPS: -1, gRPC: -1, DNS: 8600)
2023-12-15 11:57:36 Cluster Addr: 172.17.0.4 (LAN: 8301, WAN: 8302)
2023-12-15 11:57:36 Encrypt: Gossip: false, TLS-Outgoing: false, TLS-Incoming: false, Auto-Encrypt-TLS: false
2023-12-15 11:57:36
2023-12-15 11:57:36 ==> Log data will now stream in as it occurs:
...
日志显示一些有关服务器的信息和启动操作,这里采用本地代理,实际的生产设置是由多个数据中心的集群组成。这些数据中心可运行一个或多个代理,其中每个服务器中只有一个代理可以充当服务器代理。代理之间使用协议进行通信以同步信息并通过共识选举领导者,这些设置确保高可用性。如果数据中心无法访问,代理会知道并选举出新的领导者。
Consul在端口8500上运行HTTP服务,提供RESTful API,用于服务注册和发现以及其他功能,还提供一个用户界面,通过http://localhost:8500来访问,如下图所示:
Service部分显示已注册服务列表,因为还没有进行任何操作,唯一可用的服务是Consul服务器。其他选项卡可显示可用的节点、键/值、Intention、ACL等功能。
还可用通过REST API来访问可用服务列表,例如HTTPie就可以请求可用服务列表,会输出一个空的响应体,如下所示:
> http -b :8500/v1/agent/services
{}
通过Service API,可列出服务,查询服务信息,了解服务是否正常运行,对服务进行注册和注销。这里不会直接使用这些API,因为Spring Cloud Consul模块会自动完成这些操作。
Consul包括用于验证所有服务状态的功能:运行状况检查功能。它提供了多种可用于确定运行状况的选项:HTTP、TCP、脚本等。这里,计划让Consul通过HTTP接口(具体地说是/actuator/health端点)联系微服务。运行状况检查位置是在服务注册时配置的,Consul会定期触发一次(也可以自定义)。如果服务无法响应或状态为非正常(2XX除外),则Consul会将其标记为不正常。
不需要使用Consul API来注册服务、定义运行状况检查或访问注册表来查找服务地址,这些功能由Spring Cloud Consul提供,因此,在Spring Boot应用程序中包含对应的启动器,并进行一些配置即可。
首先,在Gateway项目中添加Spring Cloud Consul 发现依赖项,如下所示:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
接着,对另外两个微服务Multiplication和Gamification依次添加Spring Cloud和spring-cloud-starter-consul-discovery依赖项,如下所示:
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<!-- ... -->
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Consul自带的Spring Boot默认自动配置符合现在的情况:服务器位于http://localhost:8500。如果要查看这些默认设置,可以查看ConsulProperties的源代码,如下所示:
@ConfigurationProperties(PREFIX)
@Validated
public class ConsulProperties {
/**
* Prefix for configuration properties.
*/
public static final String PREFIX = "spring.cloud.consul";
/** Consul agent hostname. Defaults to 'localhost'. */
@NotNull
private String host = "localhost";
/**
* Consul agent scheme (HTTP/HTTPS). If there is no scheme in address - client will
* use HTTP.
*/
private String scheme;
/** Consul agent port. Defaults to '8500'. */
@NotNull
private int port = 8500;
/** Is spring cloud consul enabled. */
private boolean enabled = true;
/** Configuration for TLS. */
private TLSConfig tls;
/** Custom path if consul is under non-root. */
private String path;
//...
}
如果需要改变,使用前缀spring.cloud.consul的相关属性即可。
现在,需要在应用程序中添加一个新的配置:spring.application.name,以指定应用程序的名称。将这个属性添加到所有微服务的配置中,如下所示:
spring.application.name=multiplication
在Multiplication中设置为multiplication,Gamification中设置为gamification,Gateway中设置为gateway。
启动微服务Multiplication和Gamification,来看看它们如何通过运行状况检查进行注册的。注意,要启动RabbitMQ和Consul服务器。查看Gamification运行日志,可以找到如下行:
2023-12-21T17:42:38.706+08:00 INFO 5012 --- [gamification] [ main] o.s.c.c.s.ConsulServiceRegistry : Registering service with consul: NewService{id='gamification-8081', name='gamification', tags=[], address='host.docker.internal', meta={secure=false}, port=8081, enableTagOverride=null, check=Check{script='null', dockerContainerID='null', shell='null', interval='10s', ttl='null', http='http://host.docker.internal:8081/actuator/health', method='null', header={}, tcp='null', timeout='null', deregisterCriticalServiceAfter='null', tlsSkipVerify=null, status='null', grpc='null', grpcUseTLS=null}, checks=null}
这行提示表明了应用程序启动时通过Spring Cloud Consul进行了服务注册。可以看到请求的内容:由服务名和端口组成的id、可以对多个实例进行分组的服务名称、本地地址,以及通过HTTP对Spring Boot Actuator公开的服务运行状况端点地址进行配置的状态检查,时间间隔为10秒。
在Consul服务器端,可以看到如下输出:
2023-12-21 17:41:24 2023-12-21T09:41:24.834Z [INFO] agent: Synced service: service=multiplication
2023-12-21 17:41:32 2023-12-21T09:41:32.373Z [INFO] agent: Synced check: check=service:multiplication
2023-12-21 17:42:38 2023-12-21T09:42:38.725Z [INFO] agent: Synced service: service=gamification-8081
2023-12-21 17:42:40 2023-12-21T09:42:40.883Z [INFO] agent: Synced check: check=service:gamification-8081
使用Consul浏览器界面访问,结果如下图所示:
在Services列表中,单击gamification,显示其详细信息,可以看到该服务的运行状况,如图所示:
单击服务的各种检查项,可以看到更详细的检查结果,例如点击“All service checks passing”,如图所示:
还可以启动第二个服务实例,以查看注册表如何对其进行处理,命令行如下所示:
> ./mvnw spring-boot:run -D"spring-boot.run.arguments"="--server.port=9080"
在Consul注册表中,仍然只有一个multiplication服务,但显示有2个实例,如图所示:
当点击multiplication服务时,导航到Instances选项卡,就可以看到有2个实例了,如图所示:
每个实例都有对应的运行状况检查。
从Consul获取服务列表的API请求检索这些服务,响应如下所示:
> http -b :8500/v1/agent/services
{
"gamification-8081": {
"Address": "host.docker.internal",
"Datacenter": "dc1",
"EnableTagOverride": false,
"ID": "gamification-8081",
"Meta": {
"secure": "false"
},
"Port": 8081,
"Service": "gamification",
"Tags": [],
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication": {
"Address": "host.docker.internal",
"Datacenter": "dc1",
"EnableTagOverride": false,
"ID": "multiplication",
"Meta": {
"secure": "false"
},
"Port": 8080,
"Service": "multiplication",
"Tags": [],
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication-9080": {
"Address": "host.docker.internal",
"Datacenter": "dc1",
"EnableTagOverride": false,
"ID": "multiplication-9080",
"Meta": {
"secure": "false"
},
"Port": 9080,
"Service": "multiplication",
"Tags": [],
"Weights": {
"Passing": 1,
"Warning": 1
}
}
}
如果没有Spring抽象,会如何使用Consul服务呢?首先,所有服务只有知道HTTP主机和端口才能找到注册表;然后,如果要与Gamification API进行交互,要所有Consul的Service API来获取可用的实例列表,遵循客户端发现方法,会使用负载均衡从列表中选择一个正常的实例;然后,客户端服务知道了地址和请求的端口后,便可用执行请求。过程很繁琐,Spring Cloud Consul已经自动完成了,大大简化了操作。
网关是系统中调用其他服务的服务,已经使用Consul实现了服务发现,那么,如何实现负载均衡呢?
这里,实现一种客户端发现方法,其中后端服务查询注册表并确定如果有多个可用实例的话,应该调用哪个实例。Spring Cloud负载均衡器是Spring Cloud Commons的组件,与Consul和Eureka集成在一起,提供简单的负载均衡器实现,默认情况下,会自动配置能够迭代遍历所有实例的循环负载均衡器。
Netflix的Ribbon曾经是实现负载均衡器的首选,因为其处于维护模式,所以放弃该选项,选择Spring的负载均衡器实现。Spring Cloud负载均衡器包含在Spring Cloud Consul启动器依赖中。
要在两个应用程序之间进行负载均衡的调用,可在创建RestTemplate对象时只使用@LoadBalanced注解,然后,在执行对该服务的请求时,将服务名称用作URL中的主机名,Spring Cloud Consul和负载均衡器将完成其余工作,查询注册表并按顺序选择一个实例。下面的代码表明了如何在客户端Multiplication微服务中集成服务发现和负载均衡:
@Configuration
public class RestConfiguration {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Slf4j
@Service
public class GamificationServiceClient {
private final RestTemplate restTemplate;
public GamificationServiceClient(final RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public boolean sendAttempt(final ChallengeAttempt attempt) {
try {
ChallengeSolvedDTO dto = new ChallengeSolvedDTO(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
ResponseEntity<String> r = restTemplate.postForEntity(
"http://gamification/attempts", dto, String.class);
log.info("Gamification service response: {}", r.getStatusCode());
return r.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("There was a problem sending the attempt.", e);
return false;
}
}
}
因为已经去除了微服务之间的HTTP调用,这种方法就不适用了,但是,如果想要了解服务间HTTP交互情况,还是不错的。使用服务发现和负载均衡,可以减少失败的风险,因为这增加了至少有一个实例用于处理同步请求的机会。
这里,计划在网关中集成服务发现和负载均衡,如图所示:
在应用程序中包含Spring Cloud Consul启动器后,它们会联系注册表用于发布信息,但是,仍然可以显式地使用地址/端口来代理请求的网关。
首先在Gateway项目依赖中添加Spring Cloud Consul发现启动器,如下所示:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
下面,就想要添加配置信息,可以分成三组:
关于网关的配置如下所示:
server:
port: 8000
spring:
cloud:
consul:
discovery:
enabled: true
# 获取通过健康检查的服务
query-passing: true
gateway:
routes:
- id: multiplication
uri: lb://multiplication/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: lb://gamification/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
default-filters:
- name: Retry
args:
retries: 3
methods: GET, POST
application:
name: gateway
将query-passing设置为true,Spring将只检索那些通过运行状况检查的服务,因为只想将请求代理到正常的实例。在服务不经常轮询更新服务列表的情况下,false才可能有意义,这种情况下,需要获取整个列表,而且有处理不正常实例的机制。
最大的改变是关于URL的修改,现在使用诸如 lb://multiplication/
的URL,由于添加了Consul客户端,应用程序可以使用Service API将服务名称解析为可用实例,lb告诉Spring应该使用负载均衡。
这里还添加了适用于所有请求的网关过滤器:RetryGatewayFilter,它位于 default-filters 节点下。该过滤器拦截错误响应,透明地重试该请求,与负载均衡器结合使用,意味着该请求将被代理到下一个实例,可以方便地获取不错的弹性(重试)。这里配置重试3次,可以覆盖大多数失败情况,如果所有重试都失败,网关会向客户端返回错误响应(服务不可用)。
那么,为什么要使用重试呢?Consul的工作方式是每10秒轮询一次(默认值,看更改),当服务还没准备好处理时,注册表不会知道,可能的情况是,Consul成功地检查到了实例,然后该实例立即停止运行,注册表会保持该实例正常运行状况几秒钟,直到下一次轮询,可以使用重试来处理这种情况。更新注册表后,网关将不会在服务列表中获取不正常的实例,就不需要重试。
注意,减少两次检查的间隔时间可以减少错误数量,但会增加网络流量。
应用新配置后,Gateway微服务会连接到Consul,以查找其他微服务的可用实例及网络位置,然后,基于Spring Cloud负载均衡器中包含的简单循环算法来均衡负载。
现在的配置,所有外部HTTP通信通过localhost:8000使用网关微服务,实际生产环境中,通常使用端口80的HTTP访问,并使用DNS地址指向服务器所在的IP,但是,公共访问将只有一个入口,这成为系统的关键,必须尽可能提高可用性,如果宕机,系统就会宕机。
为了降低风险,可引入DNS负载均衡(指向多个IP地址的主机名)向网关添加冗余。但是,当其中一台主机没有响应时,它依靠客户端(如浏览器)来管理IP地址列表并处理故障转移。可将其视为网关顶部的一个额外层,以增加客户端发现、负载均衡和容错能力。这不是典型做法。
Amazon、Microsoft、Google等云提供商使用路由和负载均衡来保证托管服务的高可用性,这也是确保网关始终保持运行状态的替代方法,另外,Kubernetes允许在网关上创建负载均衡器,也可以在该层添加冗余。
下面就来看看服务发现和负载均衡。
在运行之前,在Multiplication微服务的UserController和Gamification微服务的LeaderBoardController中添加一行日志输出,便于在日志中查看与API的交互情况。如下所示:
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{idList}")
public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
log.info("解析用户别名:{}", idList);
return userRepository.findAllByIdIn(idList);
}
}
@Slf4j
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
public class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
@GetMapping
public List<LeaderBoardPosition> getLeaderBoard() {
log.info("查询排行榜");
return leaderBoardService.getCurrentLeaderBoard();
}
}
启动整个系统,包括RabbitMQ、Consul代理、Multiplication微服务、Gamification微服务、Gateway微服务、前端React等。另外给Multiplication和Gamification添加一个额外的实例,命令行如下:
multiplication> ./mvnw spring-boot:run -D"spring-boot.run.arguments"="--server.port=9080"
...
gamification> ./mvnw spring-boot:run -D"spring-boot.run.arguments"="--server.port=9081"
...
启动后,可以在Consul的界面中查看服务信息,如下图所示:
验证网关的负载均衡器是否正常很简单:检查两个Gamification服务实例的日志,通过前面添加的日志输出信息,可以方便地查看到排行榜更新的交替请求。如下所示:
2023-12-22T11:37:30.131+08:00 INFO 55672 --- [multiplication] [nio-8080-exec-7] c.z.m.challenge.ChallengeServiceImpl : attempt: ChallengeAttempt(id=154, user=User(id=3, alias=noise), factorA=50, factorB=60, resultAttempt=3000, correct=true)
2023-12-22T11:37:33.494+08:00 INFO 55672 --- [multiplication] [nio-8080-exec-8] c.z.multiplication.user.UserController : 解析用户别名:[3, 1]
大家可以多尝试一下,以观察在各个实例上的输出。
现在,系统得到了扩展,一切按预期进行。与RabbitMQ负载均衡类似,HTTP流量在所有实例之间均衡分配,将系统的容量增加了一倍。
实际上,可以启动任意数量的实例,并且负载将透明地分布在所有微服务中,通过网关,使API客户端不了解内部服务,很容易实现这些关注点,如用户身份验证或监视等。
还应该检查是否实现了其他非功能性需求:弹性、高可用性和容错性。
意外总会发生,模拟一下服务不可用的情况。现在模拟服务突然终止的情况,人为停止某个实例,查看是否能够正常。
手动终止Gamification实例2(9081端口),持续发送请求,发现所有的请求都转发到8080端口的实例上了,如图所示:
可以暂时关闭网关中的重试过滤器配置,来了解它们是如何协作的,注释调Gateway配置中的重试过滤器配置,重新启动,不使用重试模式时如图所示:
模拟前面的测试,整个系统在停止一个实例的时候,仍然能够运行。在Consul界面下,导航到Service实例下,可以看到列出来的所有可用实例,如图所示:
现在,在微服务中实现了可扩展性,通过服务发现注册表的负载均衡器实现了适当的容错性,注册表了解系统中不同组件的运行状况。
Spring Boot的优点之一是能够通过配置文件自定义配置,配置文件可以根据需要启用一组配置属性。例如,本地测试期间,可配置使用本地RabbitMQ服务器;生产环境中,可切换到实际的RabbitMQ服务器。
要引入新的rabbitprod配置文件,可以创建一个名为application-rabbitprod.properties文件。Spring Boot使用application-{profile}的命名约定(对应properties和yaml格式),从而可以在单独的文件中定义配置文件。
定义了单独的配置文件,启动应用程序时,要确保启用此配置文件,这需要设置spring.profiles.active属性。可使用maven命令为微服务Multiplication启用指定的配置文件:
> ./mvnw spring-boot:run -D"spring-boot.run.arguments"="--spring.profiles.active=rabbitprod"
可以想象,所有微服务在每种环境中都可能具有许多通用的配置,不仅RabbitMQ的连接详细信息可能是相同的,而且可以添加额外的值,例如exchange名称(amqp.exchange.attempts)。可以将值保存在每个微服务、每种环境和每个工具的单独文件中,例如:
然后,在需要的时候进行复制,方便重用这些值。但是,保留许多副本带来大量的维护工作,如果需要更改公共配置中的某个值,必须替换每个项目中的相应文件。
更好的方法是将配置放在系统中的通用位置,使应用程序在启动之前对其内容进行同步,然后对每种环境进行集中配置,这样只相应调整一次值。如图所示:
在寻找Spring的配置服务器模式下,解决方案是使用Spring Cloud Config Server,它可以保留一组配置文件,这些配置文件分布在文件夹中并通过REST API公开。在客户端,使用此依赖的项目根据其活动配置文件来访问配置服务器并请求相应的配置资源。缺点是,需要创建另外一个微服务以充当配置服务器并公开集中式文件。
替代方案是使用Consul KV,这是Consul软件包中包含的功能,Spring Cloud进行集成以实现集中式配置服务器。可以通过这种方法重用组件,结合Consul的服务发现、运行状况检查和集中式配置来简化系统。
Consul KV是随Consul安装的键/值存储,可通过REST API和用户界面访问。将Consul设置为集群时,还可以从复制中受益,这样,由于服务无法获得其配置而导致数据丢失或停机的风险较小。
在Consul用户界面中,导航到Key/Value选项卡,如图所示:
单击Create,就可以创建新的键/值对,如图所示:
可使用切换按钮在代码和普通编辑器之间切换。代码编辑器支持几种符号的语法分色,包括json、hcl和yaml等。
Consul KV REST API允许通过HTTP调用创建键/值对和文件夹,并使用键名进行检索,和服务发现一样,不需要使用REST API进行交互,Spring Cloud Consul Config进行了抽象,方便使用。
使用Consul KV实现集中式配置需要Spring Cloud Consul Config支持,要向项目中添加spring-cloud-starter-consul-config依赖,它包括自动配置类,这些类将在应用程序早期阶段尝试查找Consul代理并读取相应的KV值,之所以使用此阶段,是希望Spring Boot在其余的初始化过程中应用集中式配置值(如,连接到RabbitMQ)。
Spring Cloud Consul Config希望每个配置文件都映射到KV存储中的给定键,其值是一组Spring Boot配置值,格式为YAML或纯格式(.properties)。
可以配置一些值,以帮助应用程序在服务器中找到相应的键,下面是最相关的设置:
对于每个应用程序,必须将与配置服务器相关的所有配置值都放在一个单独的文件中,文件名为bootstrap.yml或bootstrap.properties。如图所示:
注意,连接到配置服务器的应用程序配置(bootstrap)和将本地属性(application)与那些从配置服务器下载的属性合并所产生的应用程序配置之间存在差异。第一种配置是元配置,无法从服务器下载,必须将这些值复制到项目中相应的引导程序配置文件中。下面通过实例来看看它们是如何工作的。
首先,需要在微服务项目中添加启动器:spring-cloud-starter-consul-config。代码如下:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
这样,应用程序会尝试在引导阶段连接到Consul,并使用KV自动配置中提供的默认值从KV存储中获取配置文件属性。这里会改变某些设置,以便了解其中的原理。
在Multiplication和Gamification项目在,使用的是properties格式,为保持一致,在同一文件夹中创建一个单独的文件bootstrap.properties,这两个项目中使用相同的设置,代码如下:
spring.cloud.consul.config.prefix=config
spring.cloud.consul.config.format=yaml
spring.cloud.consul.config.default-context=defaults
spring.cloud.consul.config.data-key=application.yml
这里,选择yaml作为远程配置的格式,本地文件为.properties格式,这没问题,Spring Cloud Consul Config可合并远程application.yml键中包含的值和不同格式本地存储的值。
在Gateway项目中,创建等效的bootstrap.yml文件,使用yaml进行配置,如下所示:
spring:
cloud:
consul:
config:
prefix: config
format: yaml
default-context: defaults
data-key: application.yml
使用这些设置,目的是将所有配置存储在Consul KV中名为config的根文件夹中。在内部有一个defaults文件夹,其中可能包含名为application.yml的键,其配置适用于所有微服务。可为每个应用程序或要使用的应用程序和配置文件组合设置额外的文件夹,且每个文件夹都可能包含application.yml键已经应添加或覆盖的属性。为避免在配置服务器中混淆格式,应统一使用yaml格式。现在,将bootstrap文件添加到Multiplication、Gamification和Gateway项目中,以便它们可以连接到配置服务器并查找外部化的配置(如果有),而且添加了Spring Cloud Consul Config启动器依赖。
下面,可创建如下所示的层次结构作为Consul KV中的文件夹和键,例如:
+- config
| +- defaults
| \- application.yml
| +- defaults,production
| \- application.yml
| +- defaults,rabbitmq-production
| \- application.yml
| +- defaults,database-production
| \- application.yml
| +- multiplication,production
| \- application.yml
| +- gamification,production
| \- application.yml
在Consul界面中,进入Key/Value选项卡,单击Create,如图所示:
然后,输入config/以创建与设置中的名称相同的根文件夹,如图所示:
单击新创建的项目,导航到config文件夹,如图所示:
在config文件夹下创建defaults文件夹,如图所示:
导航到config/defaults文件夹,内容为空,在此文件夹下创建一个名为application.yml的键,默认情况下,将要应用于所有应用程序的值放在这里。
那么,如果运行Multiplication应用程序时使用的活动配置文件列表等于production、rabbitmq-production、database-production,处理顺序如下(按优先级从低到高排列):
结构化配置值的方法如下:
前面已经在项目中添加了启动器依赖:spring-cloud-starter-consul-config,而且配置了bootstrap属性文件(Spring Cloud Consul 4.1.0不需要了,这些配置可以放在application属性文件中),用于覆盖某些Consul Config默认配置。要启动Multiplication,需要在application.properties中添加下列配置:
spring.config.import=optional:consul:
spring.cloud.consul.config.prefix=config
spring.cloud.consul.config.format=yaml
spring.cloud.consul.config.default-context=defaults
spring.cloud.consul.config.data-key=application.yml
在Consul KV中创建配置:config/defaults/application.yml,值为:
logging:
level:
org.springframework.core.env: DEBUG
org.springframework.amqp.rabbit.core.RabbitAdmin: DEBUG
重启Multiplication应用程序,可以在日志中看到对应的配置在起作用:
2023-12-23T15:17:47.110+08:00 DEBUG 39172 --- [multiplication] [ main] o.s.c.e.PropertySourcesPropertyResolver : Found key 'spring.application.name' in PropertySource 'environmentProperties' with value of type String
...
2023-12-23T15:17:47.481+08:00 DEBUG 39172 --- [multiplication] [)-192.168.1.234] o.s.amqp.rabbit.core.RabbitAdmin : Initializing declarations
2023-12-23T15:17:47.494+08:00 DEBUG 39172 --- [multiplication] [)-192.168.1.234] o.s.amqp.rabbit.core.RabbitAdmin : declaring Exchange 'attempts.topic'
2023-12-23T15:17:47.497+08:00 DEBUG 39172 --- [multiplication] [)-192.168.1.234] o.s.amqp.rabbit.core.RabbitAdmin : Declarations finished
2
配置config/multiplication/application.yml,设置值为:
server:
port: 10080
重启Multiplication应用程序,可以发现其服务器端口已变为10080了,配置发生作用了。可以使用下列命令使应用程序启用某些配置文件:
> mvn spring-boot:run -D"spring-boot.run.arguments"="--spring.profiles.active=production,rabbitmq-production"
现在,完成了集中式配置,使用Consul KV功能,如下图所示:
请注意,应用程序在启动时与配置服务器存在依赖关系,为避免单点故障,可将Consul配置为生产环境中高度可用。
为了使系统具有可扩展性和弹性,使用Spring Cloud Consul集成,Spring Boot的自动配置能快速建立与Consul的连接,能够提供服务发现和集中式配置功能,方便动态集成,易于实现。而且,从前面的示例可以看出,不需要编写代码,即可获得开箱即用的功能,当然,如果需要更高级的功能,需要深入学习。