14.4 提供特定应用和profile的属性

我们可以回忆一下,当Config Server客户端启动的时候,它会发送一个请求到Config Server中,这个请求的路径中会包含应用的名称和激活profile的名称。在提供配置数据的时候,Config Server会考虑这些值,并为客户端返回特定应用和特定profile的配置数据。

从客户端的角度来讲,消费特定应用和特定profile的配置属性与之前没有ConfigServer时并没有太大的差别。应用的名称可以通过spring.application.name属性(这与Eureka识别应用的属性名是相同的)来指定应用的名称。激活的profile可以通过spring.profiles.active属性进行设置(通常会通过名为SPRING_PROFILES_ACTIVE的环境变量进行设置)。

类似的,要提供面向特定应用和profile的属性,Config Server本身也没有太多需要做的。真正比较重要的是,这些属性在支撑Git仓库中该如何进行存储。

14.4.1 提供特定应用的属性

按照我们之前的讨论,使用Config Server的好处之一就是我们可以让应用中的所有微服务共享通用的配置属性。尽管如此,有些属性可能是某个服务特有的,不需要(或者不应该)与所有的服务共享。

除了共享配置之外,Config Server还能管理面向特定应用的配置属性。要实现这一点,需要将配置文件的名称命名为该应用spring.application.name属性的值。

在第13章中,我们使用spring.application.name属性为微服务提供了一个名称,将会注册到Eureka中。相同的属性也可以被配置客户端用来在Config Server中识别自身,这样Config Server就能提供该应用特有的配置。

例如,在Taco Cloud应用中,我们将应用拆分成了多个微服务,分别是ingredient-service、order-service、taco-service和user-service,我们可以在每个服务的spring.application.name属性中指定它的名称。然后,我们就可以根据各个服务的名称在Config Server的Git后端创建对应的配置YAML文件,比如ingredient-service.yml、order-service.yml、taco-service.yml和user-service.yml。图14.3为Gogs Web应用中配置仓库的文件截图。

不管服务应用的名称是什么,所有的应用都会接收来自application.yml文件的配置。但是,在向Config Server发起请求的时候,每个服务应用的spring.application.name的属性值会一同发送(作为请求路径的第一部分),如果存在匹配的属性文件,那么该文件中的属性将会一并返回。如果application.yml中通用的属性与特定应用配置文件中的属性出现重复,那么特定应用的属性会优先生效。

需要注意的是,尽管图14.3显示的是YAML配置文件,实际上,如果在Git仓库中存放properties文件,同样的规则依然有效。

image-20211021172703981

图14.3 应用特定的配置文件会根据每个应用的spring.application.name属性进行命名

14.4.2 提供来自profile的属性

在第5章中,在编写配置属性时,我们曾经看到过利用Spring profile实现特定的属性只有在给定profile处于激活状态时才生效。Spring Cloud Config Server采用与单个Spring Boot应用完全相同的方式,提供了对特定profile属性的支持,包括:

  • 提供特定profile的“.properties”或YAML文件,比如名为application-production.yml的配置文件;
  • 在一个YAML文件中提供多个profile配置组,它们之间以“—”和spring.profiles分割开。

假设我们要通过Config Server为应用所有的微服务共享Eureka配置,现在它只引用了一个Eureka开发实例,对于开发环境来说是很不错的。如果服务要在生产环境运行,那么我们可能想要将它配置成引用多个Eureka节点。

另外,尽管我们在开发环境的配置中将server.port属性设置成了0,但是服务在部署到生产环境的时候,每个服务可能会运行到独立的容器中,容器将8080端口映射到外部的端口,这样就需要所有的应用都监听8080端口。

借助profile,我们可以声明多个配置。除了已经推送到Config Server Git后端的默认application.yml文件之外,我们还可以推送另外一个名为application-production.yml的YAML文件,如下所示:

1
2
3
4
5
6
server:
port: 8080
eureka:
client:
service-url:
defaultZone: http://eureka1:8761/eureka/,http://eureka2:8761/eureka/

在应用从Config Server获取配置信息的时候,Config Server会识别哪个profile处于激活状态(位于请求路径的第二部分)。如果活跃profile是production,那么两个属性集(application.yml和application-production.yml)都将会返回,并且application- production.yml中的属性会优先于application.yml中的默认属性。图14.4为后端Git仓库的显示效果。

image-20211021172817385

图14.4 特定profile的配置文件在命名时后缀与激活profile的名称相同

我们还可以使用同样的命名约定指定适用于特定应用且特定profile的属性,也就是将属性文件命名为应用名加中划线再加profile名的形式。

例如,我们想要为名为ingredient-service的应用设置属性,而且这些属性只有当production profile处于激活状态时才有效。在这种场景下,名为ingredient-service-production.yml的文件可以包含特定应用且特定profile的属性,如图14.5所示。

image-20211021172834285

图14.5 配置文件可以适用于特定应用且特定profile的属性

对于特定profile的属性,在后端Git仓库中,我们也可以使用相同命名约定的properties文件来代替YAML。在YAML文件中,我们可以将特定profile的属性和默认profile的属性放到同一个文件中,中间使用3个中划线和spring.profiles进行分割,相关内容我们在第5章已经学习过了。

14.3 消费共享配置

除了提供中心化的配置服务器,Spring Cloud Config Server还提供了一个客户端库,它会包含在Spring Boot应用的构建文件中,允许应用成为Config Server的客户端。

将Spring Boot应用变成Config Server客户端的最简单方式就是添加如下的依赖到项目的Maven构建文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>

相同的依赖也可以在Spring Initializr中通过选择标签为Config Client的复选框添加进来。

当应用启动的时候,自动配置功能将会自动化地注册一个属性源,该属性源将会从Config Server中拉取属性。默认情况下,它会假定Config Server运行在localhost并监听8888端口。如果情况并非如此,我们可以通过设置spring.cloud.config.uri配置Config Server的位置:

1
2
3
4
spring:
cloud:
config:
uri: http://config.tacocloud.com:8888

需要清楚一点,这些属性必须要放到Config Server客户端应用的本地,比如随每个微服务打包和部署的application.yml或application.properties文件中。

现在,我们有了一个中心化的配置服务器,几乎所有的配置都将会由它来提供,每个微服务都不需要携带很多自己的配置了。正常情况下,我们只需要设置spring.cloud.config.uri属性来指定配置服务器的地址并设置spring.application.name属性为配置服务器指明当前应用即可。

哪个优先:Config Server还是服务注册中心?

我们正在设置微服务,让它们通过Config Server了解Eureka服务注册中心在什么地方。这是一种通用的方式,能够避免在应用的每个微服务中重复服务注册中心的细节信息。

同时,我们还可能会将Config Server本身注册到Eureka中,并让每个微服务像发现其他服务那样去查找Config Server。如果你喜欢这种模式,就需要将Config Server变成服务发现的客户端,并将spring.cloud.config.discovery.enabled属性设置为false。这样的话,ConfigServer会将自身以“configserver”名称注册到Eureka中。

这种方式的缺点在于,每个服务在启动的时候都要调用两次外部的服务:第一次调用Eureka发现Config Server的位置,第二次调用Config Server获取配置数据。

当应用启动的时候,Config Server客户端提供的属性源将会对Config Server发送请求。它所接收到的属性将会放到应用的环境之中。除此之外,这些属性实际上还会被缓存起来,即便Config Server停机,它们依然是可用的(我们将会在14.6节看一下在属性发生变更的时候,刷新它们的几种方式)。

到目前为止,Config Server提供的配置都非常简单,面向所有的应用和所有的profile。但有时候,我们需要提供特定应用专有的配置,或者提供当应用在特定profile处于激活状态时才可用的配置。我们看一下Config Server的另一面,看看使用它的几种方式,包括提供特定应用和特定profile的属性。

14.2 运行配置服务器

Spring Cloud Config Server为配置数据提供了中心化的数据源。与Eureka类似,我们可以将Config Server视为另一个微服务,在更大的应用中,它的角色就是为应用中的其他服务提供配置数据。

Config Server暴露了REST API,客户端(也就是其他的服务)可以通过它来消费配置属性。通过Config Server提供的配置来源于Config Server之外,通常来源于一个像Git这样的源码控制系统。图14.1阐述了它是如何运行的。

image-20211021155947793

图14.1 Spring Cloud Config Server通过支撑的Git仓库或Vault私密存储来为其他服务提供配置属性

注意,在图14.1中,我使用的是Git的图标,而不是GitHub的图标。这是很重要的,我们可以使用任意的Git实现来存储配置信息,包括但不限于GitHub。GitLab、微软的Team Foundation Server和Gogs都是合法的Config Server后端可选方案。

注意:不管使用哪个Git服务器,Config Server几乎没有什么差异。在这里,我选择使用Gogs,这是一个轻量级、易于搭建的Git服务器。更具体来讲,我在开发使用的机器运行Gogs时完全遵循了Docker中运行Gogs的指南。

将配置信息存储在像Git这样的源码控制系统中,配置可以像应用源码那样实现版本化、使用分支、添加标签、恢复和指摘(blame)。但是,为了让配置信息与使用它们的源码分离,这些配置可以独立于应用演化和版本化。

你可能注意到了,在图14.1中还包含了HashiCorp Vault。如果想要保持配置属性完全私密,并且要将它们锁起来直到需要的时候才取出,那么Vault非常有用。我们将会在14.5节中讨论如何组合使用Config Server和Vault。

14.2.1 启用配置服务器

作为更大应用系统中的一个微服务,Config Server会作为一个独立的应用进行开发和部署。所以,我们需要为Config Server创建一个全新的项目。要实现这一点,最简单的方式就是使用Spring Initializr或它的某个客户端(比如Spring ToolSuite中的New Spring Starter Project向导)。

配置:重载的术语

当我们讨论Spring Cloud Config Server的时候,会经常用到“配置(configuration)”这个术语,但是它所指的并不总是同一件事。我们将会编写配置属性来配置Config Server本身。同时,Config Server还会为应用提供配置属性。Config Server的名字中还有“Config”这个单词,这会导致一定的混乱。

在使用“configuration”这个单词的时候,我都会尽力表达清楚到底指的是哪个配置,而在代指Config Server的时候,我都会使用“Config”这个缩写形式。

我一般会将项目命名为“config-server”,但是你可以选取任何你喜欢的名称。最重要的是要选中Config Server复选框,这样就能声明对Config Server的依赖。这样做的结果就是会在所生成项目的pom.xml文件中添加如下的依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>

Config Server的版本是根据选择的Spring Cloud release train确定的。在pom.xml文件中,必须要配置Spring Cloud release train。在我编写本书的时候,最新的Spring Cloud发布版本是Finchley.SR1。所以,在pom.xml文件中将会发现如下的属性和<dependencyManagement>代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
...
<spring-cloud.version>Finchley.SR1</spring-cloud.version>
</properties>
...
<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>

尽管Config Server依赖将Spring Cloud添加到了项目的类路径下,但是这里并没有自动配置启动它,所以我们需要为某个配置类添加@EnableConfigServer。顾名思义,这个注解会在应用运行的时候启用一个Config Server。通常,我会将@EnableConfigServer放到主类中,如下所示:

1
2
3
4
5
6
7
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

在我们想要启动应用并查看Config Server如何运行之前,必须还要做另外一件事情:我们必须要告诉它,它要对外提供的配置属性都位于何处。作为开始,我们将会使用来自Git仓库的配置,所以我们需要将spring.cloud.config.server.git.uri属性设置为配置仓库的URL:

1
2
3
4
5
6
7
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

在14.2.2小节,我们将会看到如何为Git仓库填充属性。

为了在本地开发环境运行,我们可能还要配置另一个属性。在测试本地服务的时候,我们最终会有多个服务一直运行并且它们要监听localhost的不同端口。作为典型的Spring Boot Web应用,Config Server默认会监听8080端口。为了避免端口冲突,我们可以通过设置server.port属性指定一个唯一的端口号:

1
2
server:
port: 8888

在这里,我们将server.port设置为8888,是因为在14.3节中我们将会看到这是Config客户端试图获取配置信息时默认使用的端口。可以将其设置成任意值,但是在配置客户端服务中必须要与其匹配。

很重要的一点需要注意,我们此时所编写的配置是针对Config Server本身的。它与Config Server对外提供的配置是不同的。Config Server会对外提供从Git或Vault获取到的配置信息。

此时,如果启动应用,就会有一个监听8888端口的Config Server,它还不能提供任何的配置属性。我们目前还没有任何Config Server客户端,但是可以通过curl命令行(或者提供同样功能的HTTP客户端)模拟一个客户端:

1
2
3
4
5
6
7
8
9
10
11
$ curl localhost:8888/application/default
{
"name": "application",
"profiles": [
"default"
],
"label": null,
"version": "ca791b15df07ce41d30c24937eece4ec4b208f4d",
"state": null,
"propertySources": []
}

它会向Config Server的“/application/default”路径发送HTTP GET请求。这个请求可以由两部分或3部分组成,如图14.2所示。

image-20211021160333988

图14.2 Config Server对外暴露了一个REST API(通过它可以消费配置属性)

路径的第一部分,即“application”,指的是发送请求的应用的名称。在14.4.1小节中将会看到,Config Server是如何利用请求路径中这部分的内容为我们提供特定应用配置的。现在,我们没有特定应用的配置,所以任意值都是可以的。

路径的第二部分指的是发送请求的应用中处于激活状态的Spring profile。在14.4.2小节中,我们将会看到Config Server是如何利用请求路径中的profile值提供激活active特定配置的。我们目前没有特定profile的配置,所以任意的profile值都是可以的。

路径的第三部分是可选的,指定了提供配置信息的后端Git仓库的标签或分支。如果没有指定,那么默认会使用“master”分支。

请求的响应为我们提供了一些关于Config Server的基本信息,包括为我们提供配置信息的Git提交的版本和标签。但是,这里明显缺少的就是真正的实际配置信息。正常情况下,我们会在propertySources属性下看到它们,但是在这个响应中,它是空的。这是因为我们需要为Git仓库填充Config Server要对外提供的属性。现在,我们看一下该如何实现。

14.2.2 填充配置仓库

我们有多种办法为Config Server提供属性,最基本、最直接的方案是提交application.properties或application.yml文件到Git仓库的根路径下。

假设我们已经推送了一个名为application.yml的文件到前面章节所配置的Git仓库下。这个配置文件与前面章节的配置是不同的,它是Config Server将要对外提供的配置。假设在这个application.yml文件中我们配置了如下的属性:

1
2
3
4
5
6
server:
port: 0
eureka:
client:
service-url:
defaultZone: http://eureka1:8761/eureka/

尽管这个application.yml文件的内容并不多,但是它所定义的配置是相当重要的。它会告诉应用中的每个服务都选择任意可用的端口并且告诉它们进行服务注册的Eureka在哪里。这意味着,在14.3节中,当我们将服务变成Config Server客户端的时候,我们可以从服务中移除显式的Eureka配置。

作为Config Server的客户端,我们可以使用curl命令行查看Config Server提供的新配置数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ curl localhost:8888/someapp/someconfig
{
"name": "someapp",
"profiles": [
"someconfig"
],
"label": null,
"version": "95df0cbc3bca106199bd804b27a1de7c3ef5c35e",
"state": null,
"propertySources": [
{
"name": "http://localhost:10080/habuma/tacocloudconfig/
application.yml",
"source": {
"server.port": 0,
"eureka.client.service-url.defaultZone":
"http://eureka1:8761/eureka/"
}
}
]
}

与之前对Config Server的请求不同,这个响应的propertySources属性中有了内容。具体来讲,它包含了一个属性源,属性源的name属性指向了Git仓库的引用,source则包含了我们推送至Git仓库中的属性。

从Git子路径下提供配置

按照代码的组织风格,你可能想要将配置信息存储到Git仓库的子目录下,而不是放到根路径下。例如,我们想要将配置放到相对于Git仓库根目录名为“config”的子目录下,就可以按照如下方式设置spring.cloud.config.server.git.search-paths属性,让Config Server不再从根目录而是从“/config”目录下提供配置信息:

1
2
3
4
5
6
7
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
search-paths: config

注意,spring.cloud.config.server.git.search-paths属性是一个复数形式,这意味着我们可以让Config Server提供来自多个路径的配置,只需将它们列出来以逗号分隔即可:

1
2
3
4
5
6
7
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
search-paths: config,moreConfig

这样的话,Config Server会提供Git仓库下来自“/config”和“/moreConfig”路径的配置。

我们还可以使用通配符指定搜索路径:

1
2
3
4
5
6
7
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
search-paths: config,more*

这里,Config Server会提供来自“/config”和所有以“more”开头的子目录的配置。

从Git分支或标签下提供配置

默认情况下,Config Server会提供Git中master分支下的配置。在客户端,我们可以将特定分支或标签设置为请求Config Server路径的第三个成员,如图14.2所示。但是,我们可能会发现让Config Server默认请求Git下特定的标签或分支会非常有用,而不是默认使用master。spring.cloud.config.server.git.default-label属性可以重写默认的标签或分支。

例如,考虑如下的配置,它会让Config Server提供名为“sidework”的分支(或标签)下的配置:

1
2
3
4
5
6
7
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
default-label: sidework

按照这个配置形式,除非Config Server客户端指定,否则将会提供“sidework”分支下的配置。

为Git后端提供认证

Config Server检索配置信息的后端Git仓库很可能会使用用户名和密码进行保护。如果是这样,我们就必须为Config Server提供Git仓库的凭证信息。

spring.cloud.config.server.username和spring.cloud.config.server.password属性可以为后端仓库设置用户名和密码。如下的Config Server配置将设置这些属性:

1
2
3
4
5
6
7
8
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
username: tacocloud
password: s3cr3tP455w0rd

在这里,分别将用户名和密码设置成了tacocloud和s3cr3tP455w0rd。

使用curl作为Config Server的客户端能够帮助我们体验一下Config Server是怎样运行的。实际上,Config Server所能做的远远不止于此。但是,我们所编写的微服务并不会使用curl来获取配置数据。所以在查看Config Server提供配置的其他方式之前,我们将关注点转移到微服务上,看一下如何将它们变成Config Server的客户端。

14.1 共享配置

就像我们在第5章所看到的那样,我们可以通过多种属性源设置属性来对Spring应用进行配置。如果某个配置属性可能会更改或者只针对运行时环境有效,那么Java系统属性或操作系统环境变量是一个合适的可选方案。对于不太可能发生变化或者应用特定的属性,将它们放到application.yml或application.properties中,随着打包的应用一起部署是一种很好的方案。

这些方案对于简单的应用来说都很不错。但是,当在环境变量或Java系统属性中设置配置属性的时候,我们必须要接受这样一个现实,那就是修改这些属性需要应用重启。如果我们选择将属性打包到要部署的JAR或WAR文件中,那么在属性变更时,我们必须要完全重新构建和重新部署应用。如果我们想要回滚配置变更,那么同样的约束依然有效。

这些约束在有些应用程序中是可以接受的。但是,在有些情况下,如果仅仅是为了修改一个属性就重启应用,往好了说是不太方便,往坏了说则具有破坏性。除此之外,在基于微服务架构的应用中,属性管理会跨越多个代码库和部署实例,因此将相同变更用到应用中多个服务的每个实例中是不现实的。

有些属性是敏感的,比如数据库密码和其他类型的私密信息。尽管这些值作为应用的属性在写入的时候可以进行加密,但是应用在使用它们之前必须要先解密。即便如此,有些属性甚至可能需要对应用开发人员保密。这样的话,将它们设置成环境变量或者将它们与应用的其他代码一起通过源码控制系统进行管理就是不可取的了。

相反,我们可以考虑一下这些场景在集中式的配置管理下会是什么样子。

  • 配置不再需要和应用程序代码一起打包和部署。这样的话,配置的变更或回滚就都不需要重新构建和重新部署应用了。配置甚至可以在运行时进行变更,无须重新启动应用。
  • 共享通用配置的微服务不需要管理自己的属性设置副本,并且能够管理共享的相同属性。如果需要对属性进行变更,那么这些变更只需在一个地方执行一次就可以应用到所有的微服务上。
  • 敏感配置可以进行加密,并且能够与应用代码分开进行维护。应用可以按需获取未加密的值,而不需要应用程序提供解密信息相关的代码。

Spring Cloud Config Server提供了中心化的配置功能,应用中的所有微服务均可以依赖该服务器来获取配置。因为它是中心化的,所以是一个一站式的配置商店,所有的服务都可以使用它,另外它还能够为特定服务提供专门的配置。

使用Config Server的第一步就是创建并运行该服务器。

第14章 管理配置

本章内容:
  • 运行Spring Cloud Config Server
  • 创建Config Server的客户端
  • 存储敏感配置
  • 自动化刷新配置

买过房子或汽车的人可能都会面临厚厚的一叠纸。购买大宗商品时要签署的合同往往会对无纸化社会的承诺不屑一顾。每当我与汽车经销商或代理人坐到一起的时候,都感觉我应该提前准备好一叠绷带,为这个过程中几乎总能出现的纸划伤手的情况做好准备。

近年来,尽管我必须要签署的总页数几乎没有什么变化,但是我不必像以前那样填写那么多的字段了。对于表格中那些曾经手动填写的地方,现代化的表格在打印之前通常就基于收集到的数据预先填充好了。这样的话,不但会加快处理速度,而且能够减少在多个表格间手动填写重复数据所导致的错误。

与之类似,很多应用程序都存在某种形式的配置。在第5章中,我们讨论了通过配置属性来设置Spring Boot应用。通常,我们设置的属性是该应用特有的,所以可以通过application.properties或application.yml文件声明这些属性,并将它们打包到应用的部署文件中。

按照微服务的方式来组织架构的话,多个服务之间的配置属性是通用的。就像手工填写带有重复数据的表单非常乏味而且易于出错一样,跨多个应用服务重复进行配置可能也会存在问题。

在本章中,我们将会研究Spring Cloud的Config Server,这是为指定应用中所有服务提供集中式配置的一个服务。借助配置服务器,我们可以在一个地方管理所有的应用配置,避免任何重复。

但是在开始之前,我们简单思考一下单独配置微服务的问题,以及中心化的配置为何能够更好。

13.4 小结

  • 借助自动配置和@EnableEurekaServer注解,Spring Cloud Netflix能够让我们很容易地创建Netflix Eureka服务注册中心。
  • 微服务可以使用名字将它们自身注册到Eureka中,这样可以被其他服务发现。
  • 在客户端,作为客户端负载均衡器,Ribbon能够根据名称查找服务并选择实例。
  • 客户端代码可以使用RestTemplate,利用Ribbon进行负载均衡;也可以将REST客户端定义为接口,由Feign在运行期自动实现。
  • 不管采用哪种方案,客户端代码都不需要硬编码它们所消费的服务的地址。

13.3 注册和发现服务

没有服务注册的话,Eureka服务注册中心没有任何用处。如果你的服务想要被其他服务发现和消费,就需要将它们作为服务注册中心的客户端。为了让应用(任何应用,但很可能是微服务)成为服务注册中心的客户端,我们至少需要将Eureka客户端依赖添加到服务应用的构建文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

与Eureka服务器starter依赖类似,我们还需要为Spring Cloud的依赖管理设置Spring Cloud的版本属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
...
<spring-cloud.version>Finchley.SR1</spring-cloud.version>
</properties>
...
<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>

我们可以手动添加这些条目到服务应用的pom.xml文件中,但是更简单的方式是在Spring Initializr的复选框中选中Eureka Discovery依赖。

Eureka client starter依赖会添加通过Eureka发现服务所需的所有内容,包括Eureka的客户端库以及Ribbon负载均衡器。我们只需要将这个依赖添加进来,就能将应用变成Eureka服务注册中心的客户端。当应用启动的时候,它会尝试联系在本地运行并且端口为8761的Eureka服务器,并将自身基于UNKNOWN名称进行注册。

13.3.1 配置Eureka客户端属性

对于开发阶段来说,默认位置的Eureka服务器是可以接受的,如果我们要将服务部署到localhost之外,就需要覆盖它的值。另外,默认的服务名为UNKNOWN,这是一个非常糟糕的选择……但是,坦白来讲,任何形式的默认方案都会很糟糕,因为如果采用默认方案,那么所有服务都会具有相同的名称。

更改服务在Eureka中的注册名称非常简单,我们只需要设置spring.application.name属性就可以了。例如,如果想要注册一个处理taco配料相关操作的服务,那么我们可以将其注册为ingredient-service。在application.yml中,将会如下所示:

1
2
3
spring:
application:
name: ingredient-service

设置完这个属性之后,我们就可以按照ingredient-service名称来查找服务了。另外,如果我们为配料服务添加多个实例,它们就会以相同的名称出现在注册中心,实际上,服务会扩展到多个实例,并假定它们是完全相同的,服务的消费者可以从中选择。此时,我们查看Eureka dashboard的话,服务将会如图13.5所示。

image-20211021153847278

图13.5 Eureka dashboard上的配料服务

在继续使用Spring Cloud的过程中,你会发现spring.application.name是我们要设置的最重要的属性之一。它决定了Eureka中的注册名。在第14章中我们将会看到,这个属性会帮助配置服务识别该应用,用来管理特定应用的配置。其他的Spring Cloud项目,如Spring Cloud Task(短暂存活的微服务)和Spring CloudSleuth(分布式跟踪),同样依赖spring.application.name属性来识别服务。

正如我们在第1章所学到的,默认情况下,所有的Spring MVC和Spring WebFlux应用都会监听8080端口。因为这些服务现在只会通过Eureka进行查找,所以它们监听什么端口也就无所谓了,Eureka能够知道它们使用的是什么端口。为了避免本地运行时潜在的端口冲突,我们可以将端口设置为0:

1
2
server:
port: 0
注意:将端口设置成0的话,应用会选择任意一个可用端口来启动。

现在,我们要考虑Eureka服务器的位置。默认情况下,Eureka客户端会假定Eureka服务器在本地运行(8761端口)。对于开发期来说,这种方式很不错,但是在生产环境中,大多数情况并非如此。因此,我们需要指定Eureka服务器的位置。这与Eureka服务器本身的实现方式完全相同,都是要使用eureka.client.service-url属性:

1
2
3
4
eureka:
client:
service-url:
defaultZone: http://eureka1.tacocloud.com:8761/eureka/

通过这样的配置,客户端会使用eureka1.tacocloud.com主机(端口8761)上的Eureka服务器进行注册。只要Eureka服务器在运行,这种方式就是没有问题的,但是一旦Eureka服务器因为某种原因而停机,服务注册就会失败。为了避免注册失败,最好是为服务配置两个或更多的Eureka实例:

1
2
3
4
5
eureka:
client:
service-url:
defaultZone: http://eureka1.tacocloud.com:8761/eureka/,
http://eureka2.tacocloud.com:8762/eureka/

当服务启动的时候,它会尝试使用zone中的第一个服务器进行注册。如果因为某种原因失败,它将会使用列表中的下一个服务器来进行注册。最终,如果出现故障的服务器重新恢复在线状态,它将会从对等的端上复制注册信息,这样就能将该服务的注册条目包含进来。

在Eureka中注册服务只完成整个任务的一半。服务在Eureka注册之后,其他的服务就可以发现和消费它们了。接下来,我们看一下如何消费Eureka中注册的服务。

13.3.2 消费服务

在消费者代码中,硬编码任何服务实例的URL都是错误的做法。这不仅会让消费者与服务的特定实例耦合在一起,而且一旦服务的主机和/或端口改变,消费者就会出问题。

对于消费者应用来说,在从Eureka中查找服务时,它要承担很多责任。Eureka可能会基于查找结果返回同一个服务的多个实例。如果消费者请求ingredient-service服务时得到了多个服务实例,那么它该如何选择正确的服务呢?

好消息是消费者应用根本不需要从中进行选择,甚至都不需要自己显式地进行服务查找。借助Spring Cloud的Eureka客户端支持和Ribbon客户端负载均衡器,我们可以很容易地查找、选择和消费服务实例。我们有两种方式可以消费从Eureka中查找到的服务:

  • 支持负载均衡的RestTemplate;
  • Feign生成的客户端接口。

选择哪种方式在很大程度上取决于个人喜好。下面我们将会看一下这两种方案(首先会介绍支持负载均衡的RestTemplate),然后你就可以从中选择最喜欢的方式了。

使用RestTemplate消费服务

你对Spring RestTemplate客户端的第一印象可能来源于第7章。我们快速回忆一下它的运行原理,在创建或注入RestTemplate之后,我们就可以发送HTTP调用并将响应绑定到领域类型上。例如,为了发送根据ID获取配料的HTTP GET请求,我们可以使用如下的RestTemplate代码:

1
2
3
4
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject("http://localhost:8080/ingredients/{id}",
Ingredient.class, ingredientId);
}

在这里,唯一的问题在于getForObject()的URL硬编码了特定的主机和端口。我想,你可能会将细节信息提取到一个属性中,但是如果我们将请求的目的地设置成配料服务众多实例中的某一个,那么我们所配置的URL会始终都指向同一个特定实例,这样就没有负载均衡器将请求分散到多个服务实例中了。

如果我们将应用变成Eureka客户端,就可以声明支持负载均衡的RestTemplatebean了。我们需要做的就是声明一个常规的RestTemplate bean,并为带有@Bean注解的方法再添加上@LoadBalanced

1
2
3
4
5
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}

@LoadBalanced注解有两个目的。首先,也是最重要的,它会告诉SpringCloud,这个RestTemplate要能够通过Ribbon来查找服务。其次,它会作为一个注入限定符(qualifier),所以有两个或更多RestTemplate bean的话,我们可以在注入的地方声明此处想要支持负载均衡的RestTemplate。

例如,就像上面的代码那样,我们想要使用支持负载均衡的RestTemplate来查找配料。首先,我们将支持负载均衡的RestTemplate注入需要它的bean中:

1
2
3
4
5
6
7
8
@Component
public class IngredientServiceClient {
private RestTemplate rest;
public IngredientServiceClient(@LoadBalanced RestTemplate rest) {
this.rest = rest;
}
...
}

随后,我们稍微修改一下getIngredientById()方法,使用服务的注册名,而不再明确使用主机和端口:

1
2
3
4
5
public Ingredient getIngredientById(String ingredientId) {
return rest.getForObject(
"http://ingredient-service/ingredients/{id}",
Ingredient.class, ingredientId);
}

发现区别了吗?getForObject()的URL不再使用特定的主机名或端口。在主机名和端口的位置上,我们使用了服务名ingredient-service。在内部, RestTemplate会要求Ribbon根据名称查找服务并从中选择一个实例。Ribbon非常乐于效力,它会将URL重写为选定服务实例的主机和端口,然后让RestTemplate像以往那样进行处理。

我们可以看到,使用支持负载均衡的RestTemplate与标准RestTemplate并没有太大的差异。关键的不同点在于客户端需要使用服务名,而不是显式的主机名和端口。如果你想使用WebClient来替代RestTemplate该怎么办呢?WebClient也能够和Ribbon组合使用根据名称来消费服务吗?

使用WebClient消费服务

在第11章中,我们看到WebClient提供了与RestTemplate类似的HTTP客户端,但是它使用的是像Flux和Mono这样的反应式类型。如果你曾经被反应式编程的bug所困扰,那么你可能倾向于直接使用WebClient,而不是使用RestTemplate。好消息是,我们可以按照与RestTemplate类似的方式将WebClient作为支持负载均衡的客户端。我们需要做的第一件事就是声明一个返回WebClient.Builder bean的方法,该方法要添加@LoadBalanced注解:

1
2
3
4
5
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}

在声明完WebClient.Builder之后,我们就可以将支持负载均衡的WebClient.Builder注入任何需要它的地方。例如,我们可以将它注入IngredientServiceClient的构造器中:

1
2
3
4
5
6
7
8
9
@Component
public class IngredientServiceClient {
private WebClient.Builder wcBuilder;
public IngredientServiceClient(
@LoadBalanced WebClient.Builder webclientBuilder wcBuilder) {
this.wcBuilder = wcBuilder;
}
...
}

最后,在我们需要使用它的时候,可以利用WebClient.Builder构建一个WebClient,然后就能够使用Eureka注册的服务名来发送请求了:

1
2
3
4
5
6
public Mono<Ingredient> getIngredientById(String ingredientId) {
return wcBuilder.build()
.get()
.uri("http://ingredient-service/ingredients/{id}", ingredientId)
.retrieve().bodyToMono(Ingredient.class);
}

与支持负载均衡的RestTemplate类似,在发送请求的时候,这里不需要明确指定主机和端口。系统会从给定的URL中抽取出服务名,通过这个名称在Eureka中查询服务。Ribbon会选择服务的一个实例,在真正发送请求之前,会根据所选实例的主机和端口重写URL。

这种编程模型非常容易掌握,若你已经熟悉RestTemplate或WebClient则更是如此。Spring Cloud还有一个技巧,接下来我们看一下如何使用Feign创建基于接口的服务客户端。

定义Feign客户端接口

Feign是一个REST客户端库,使用一种特殊的、接口驱动的方式来定义REST客户端。简而言之,如果你喜欢Spring Data自动实现repository接口的方式,那么你肯定会喜欢Feign的。

Feign最初是Netflix的一个项目,后来变成了独立的开源项目,名为OpenFeign。单词feign的意思是“伪装”,稍后我们将会看到对于假装成REST客户端的项目,这是一个很合适的名称。

要使用Feign,我们首先需要将依赖添加到项目的构建文件中。在pom.xml文件中,如下的<dependency>就可以完成该任务:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

在使用Spring Initializr的时候,我们可以通过选中Feign复选框自动添加该starter依赖。令人遗憾的是,目前不会根据已有的依赖启用自动配置功能。所以,我们需要将@EnableFeignClients添加到某个配置类上:

1
2
3
4
@Configuration
@EnableFeignClients
public RestClientConfiguration {
}

现在,到了有意思的部分。假设我们想要通过注册在Eureka中名为ingredient-service的服务获取一个Ingredient,需要做的就是定义如下的接口:

1
2
3
4
5
6
7
8
9
10
package tacos.ingredientclient.feign;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import tacos.ingredientclient.Ingredient;
@FeignClient("ingredient-service")
public interface IngredientClient {
@GetMapping("/ingredients/{id}")
Ingredient getIngredient(@PathVariable("id") String id);
}

这是一个很简单的接口,并没有实现类。在运行期,当Feign发现它的时候,这一切就都不重要了,Feign会自动创建一个实现类并将其暴露为Spring应用上下文中的bean。

仔细观察一下,我们会发现其中有一些注解在发挥作用,并将所有功能组合在了一起。接口上的@FeignClient注解会指定该接口上的所有方法都会对名为ingredient-service的服务发送请求。在内部,服务将会通过Ribbon进行查找,这与支持负载均衡的RestTemplate运行方式是一样的。

随后就是getIngredient()方法,它使用了@GetMapping注解。你会发现,这个注解来源于Spring MVC。确实,就是同一个注解。现在它用在了客户端,而不是用在控制器上。它表明,任何对getIngredient()的调用都会对“/ingredients/{id}”路径发起GET请求,其中的主机和端口是通过Ribbon选定的。@PathVariable注解同样来自Spring MVC,会将方法参数映射到给定路径的占位符上。

现在,我们需要做的就是将Feign实现的接口注入需要的地方并开始使用它。例如,要在控制器中使用它,我们可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@RequestMapping("/ingredients")
public class IngredientController {
private IngredientClient client;
@Autowired
public IngredientController(IngredientClient client) {
this.client = client;
}
@GetMapping("/{id}")
public String ingredientDetailPage(@PathVariable("id") String id,
Model model) {
model.addAttribute("ingredient", client.getIngredient(id));
return "ingredientDetail";
}
}

我不知道你的观点如何,但是我觉得这非常流畅!很难说我最喜欢哪种方式:支持负载均衡的RestTemplate、WebClient,还是具有魔力的Feign客户端接口。不管选择哪种方式,我们的REST客户端都能根据名称消费在Eureka注册的服务,避免硬编码特定的主机名和端口。

值得一提的是,Feign提供了自己的注解。@RequestLine和@Param非常类似于Spring MVC中的@RequestMapping和@PathVariable,但是它们的使用方式略有差异。能够在客户端使用我们已经非常熟悉的Spring MVC注解是非常棒的,而且它们很可能与我们在定义服务控制器时所使用的注解是一样的。

第4部分 云原生Spring

第4部分将会拆分单体应用模型,我们会介绍Spring Cloud和微服务的开发。在第13章中,简单介绍微服务之后,我们将会深入介绍服务发现,这里会使用Spring和Netflix的Eureka服务注册中心实现基于Spring的微服务的注册和发现。第14章通过Spring Cloud的Config Server探讨中心化的配置,Config Server服务能够为应用中的所有微服务提供中心化的配置。在第15章中,我们将会借助NetflixHystrix实现断路器模式,让服务面对失败时更具弹性。

13.1 思考微服务

到目前为止,我们都是将Taco Cloud开发为单个应用程序,它会构建为一个可部署的JAR或WAR。单个可部署的文件似乎是一种很自然的选择。毕竟,几十年来,大多数的应用程序都是这样部署的。即便可能会将应用程序拆分为多个模块进行构建,但最终我们还是形成一个JAR或WAR,并将其投入到生产环境之中。

在构建小型、简单应用程序的时候,这当然是显而易见的方式。有意思的是,小型应用程序往往会不断增长。当需要新特性的时候,我们能够轻而易举地向项目中添加更多的代码。在我们发觉之前,它已经变成了一个复杂的单体应用,甚至有自己的思想。就像电影《小鬼怪》(Gremlins)里的Mogwai一样,如果你一直喂它,它最终会变成一个与你作对的怪物^1

单体应用看似简单,但是它会面临各种挑战,如下所示。

  • 单体应用难以理解:代码库越大,理解每个组件在整个应用程序中所担任的角色就越困难。
  • 单体应用难以测试:随着应用的不断增长,全面的集成和验收测试会变得更加复杂。
  • 单体应用更容易出现库冲突:实现某个特性所需要的依赖可能会与其他特定的依赖不兼容。
  • 单体应用的扩展较为低效:如果处于扩展的目的要将应用程序部署到更多的硬件上,那么我们必须要将整个应用部署到更多的服务器上,即便应用程序中很小的一部分需要扩展也同样如此。
  • 单体应用中的技术决策是针对整个单体应用的:当为应用程序选择语言、运行时平台、框架和库的时候,整个应用程序都会遵循我们的选择,即便我们所做的选择只是为了支持某个单独的用户场景时同样如此。
  • 单体应用需要大量的操作过程才能投入生产环境:当应用程序只有一个部署单元时,似乎更容易将其投入生产环境。事实上并非如此,单体应用程序的规模和复杂性通常需要更严格的开发过程和更周全的测试周期,这样才能保证所部署的应用程序是高质量的,才能避免引入bug。

在过去的几年间,微服务架构的出现致力于解决这些挑战。简而言之,微服务架构是将应用程序分解为可独立开发和部署的小规模、微型应用的一种方式。这些微服务之间互相协作,以实现更大的应用程序的功能。与单体应用程序架构相比,微服务架构有以下特点。

  • 微服务易于理解:每个微服务与应用程序的其他微服务之间有一个很小且有限的契约。因此,微服务更加专注于目标,作为一个单元,微服务更易于理解。
  • 微服务易于测试:事情越小,就越便于测试。当你思考单元测试、集成测试和验收测试的时候,这一点非常明显。它也适用于微服务与单体应用之间的测试。
  • 微服务较少受到库不兼容的影响:因为每个微服务都有自己的构建依赖项的集合,而这些依赖项不会与其他的微服务共享,所以不太可能会出现库冲突的现象。
  • 微服务能够独立扩展:如果指定的微服务需要更多的处理能力,那么内存分配和/或实例数量可以按比例增加,而不会影响整体应用中其他微服务的内存和实例数量。
  • 每个微服务可以选择不同的技术:每个微服务可以选择完全不同的语言、平台、框架和库。实际上,某个使用Java编写的微服务与另一个使用C#编写的微服务进行协作是完全合理的[^2]。
  • 微服务可以更加频繁地发布到生产环境中:尽管微服务架构的应用是由许多微服务组成的,但是部署每个微服务的时候,并不需要其他的微服务都已经部署就绪。而且,因为它们更小、更集中、更易于测试,所以将微服务投入到生产环境不需要那么多的繁文缛节。从产生想法到将其投入生产的耗时可以用分钟和小时计量,而不是用周和月。

显然,微服务能够让事情变得更简单。但是公平地讲,微服务架构并不是免费的午餐。微服务架构是一种分布式架构,有自己需要应对的挑战,包括网络延迟。在迁移至微服务架构时,我们需要记住这一点,因为很多的远程调用会累积并降低应用的速度。

你还要考虑是否应该将应用构建为微服务,因为并不是所有的应用程序都需要这种架构,或者说能从这种架构中受益。如果你的应用相对比较小或者比较简单,那么最初最好依然采用单体架构。随着它的不断发展,再考虑将其拆分为微服务。

在开发云原生、微服务架构的应用时,要考虑很多因素。本章和接下来的几章主要关注Spring Cloud所提供的技术,以开发由微服务组成的应用程序。如果你对深入研究云原生应用程序的设计和思想过程感兴趣,那么建议阅读Cornelia Davis的Cloud Native(Manning,2019)。

微服务架构所面临的另外一个常见挑战就是每个服务该如何知道它要协作的其他服务在哪里。这恰好是本章的主题。事不宜迟,我们马上看一下如何使用SpringCloud搭建一个服务注册中心。

[^2]: 在这里,我们会关注如何使用Java和Spring编写微服务。如果你对如何使用.NET编写微服务并与Spring Cloud服务交互感兴趣,那么可以参考一下Steeltoe。

第13章 注册和发现服务

本章内容:
  • - 思考微服务
  • - 创建服务注册中心
  • - 注册和发现服务

你看过《海底总动员》(Finding Nemo)吗?在这部电影中,马林(小丑鱼)和多莉(蓝唐王鱼)试图去澳大利亚悉尼寻找马林失踪的儿子尼莫。在路上,它们遇到了一群翻车鱼。为了好玩儿,这些翻车鱼把自己摆成了很多种形状——剑鱼、八眼鱼,它们甚至还摆成马林的样子来模仿它。当多莉问它们是否知道如何到达悉尼时,它们组成了悉尼歌剧院的形状,然后变成了一个指向东澳大利亚洋流的箭头。

虽然这部电影没有深入介绍每条翻车鱼的生活,但是我们可以假定每条鱼都是独立于其他翻车鱼的个体。它们都有自己的鳞片、鳍、鳃、眼睛、内脏,据我们所知,它们还有各自的希望和梦想。尽管如此,它们还是一起努力形成这些有趣的形状,帮助马林和多莉前往澳大利亚。

本章我们将会讨论如何开发翻车鱼所组成的应用程序,这是一系列章节中的第一章。也就是说,你将会看到如何使用微服务(一些小的、独立的应用程序,它们协同工作以提供完整应用的功能)进行开发。

更具体地讲,我们将会看到如何使用Spring Cloud套件中一些最有用的组件,包括配置管理、容错以及本章的主题即服务发现。但是,在此之前,我们快速、整体地了解一下使用微服务开发意味着什么以及它们能够提供哪些收益。