Config 入门教程 - 中级篇

上篇 - Config 入门教程(基础篇)

前言

版本说明

在下面的的教程中,使用的 Spring Cloud 版本是 Finchley.RELEASE,对应的 Spring Boot 版本是 2.0.3,特别声明除外。

Config 使用技巧

本地参数覆盖远程参数

在某些时候需要使用当前系统的环境变量或者是应用本身设置的参数而不是使用远程拉取的参数,此时 Config Client 可以使用如下配置:

官方 Bug 解决方案:

1
2
3
4
5
6
spring:
cloud:
config:
overrideNone: true
allowOverride: true
overrideSystemProperties: false
  • overrideNone:当 allowOverride 为 true 时,overrideNone 设置为 true,代表外部配置的优先级更低,而且不能覆盖任何已存在的属性源,默认为 false
  • allowOverride:标识 overrideSystemProperties 属性是否启用,默认为 true,设置为 false 表示禁止用户的个性化设置
  • overrideSystemProperties:用来标识外部配置是否能够覆盖系统属性,默认为 true

服务端 Git 配置详解

Git 中 URI 占位符

Spring Cloud Config Server 支持占位符的使用,支持 {application}{profile}{label},这样的话就可以在配置 uri 的时候,通过占位符使用应用名称来区分应用对应的仓库然后进行使用。下面举例说明 {application} 占位符的使用,点击下载完整的案例代码。

Config Server 的 application.yml 配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 9090

spring:
application:
name: config-server
cloud:
config:
server:
git:
#根据不同的应用,使用不同的Git仓库,这里需要仓库名称和仓库下面的配置文件名称一致才可以
uri: https://gitee.com/peter/{application}
username: admin
password: admin
search-paths: book-config

Config Client 的 bootstrap.yml 配置文件如下:

1
2
3
4
5
6
7
spring:
cloud:
config:
label: master #Git分支的名称
profile: dev #本次访问的配置项
uri: http://localhost:9090 #Config Server的地址
name: spring-cloud-config #需要从远程Git仓库读取的配置文件的名称,注意没有"yml"文件后缀

使用上面的配置后,Config Client 请求 Config Server 仓库的连接地址的 uri 变成了 https://gitee.com/peter/spring-cloud-config,连接到了 spring-cloud-config 仓库;其中仓库的名称是由 Config Client 的 spring.cloud.config.name 属性指定,请求的配置文件的完整路径是 https://gitee.com/peter/spring-cloud-config/book-config/spring-cloud-config.yml;值得注意的是,这里需要仓库名称和仓库下面的配置文件名称一致才可以。

路径搜索占位符

Spring Cloud Config Server 可以使用 searchPaths 参数进行路径的搜索,支持根据路径和路径前缀等方式进行配置文件的获取。

下述配置中的 book-config 表示匹配当前路径下面所有的配置文件信息,book-config* 表示在以 book-config 为前缀的文件夹内搜索所有配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server:
port: 9090

spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://gitee.com/peter/spring-cloud-config
username: admin
password: admin
search-paths: book-config, book-config*

下述配置中使用占位符的形式进行目录搜索,这样就可以根据不同的项目,对不同的配置文件进行路径搜索,从而很好地划分配置文件。值得注意的是,这里占位符的前后需要加上单引号,否则占位符无法生效。

1
2
3
4
5
6
7
8
9
10
11
12
spring:
application:
name: config-server
cloud:
config:
server:
git:
#根据不同的应用,搜索不同的目录路径,这里需要目录名称和目录里的配置文件名称一致才可以
uri: https://gitee.com/peter/spring-cloud-config
username: admin
password: admin
search-paths: '{application}'

模式匹配和多个存储库

applicationprofile 的使用上,Spring Cloud Config Server 还支持更复杂配置模式,可以使用通配符 {application}/{profile} 进行规则匹配,多个规则需要通过逗号分隔。以下配置中的 spring.cloud.config.server.uri 指明了默认的仓库地址,在使用 {application}/{profile} 匹配不上任何一个仓库时,会使用默认的仓库进行匹配来获取信息。对于 spring-cloud-config-simples 匹配的是 spring-cloud-config-simples/*,需要注意的是其仅能匹配应用名称为 spring-cloud-config-simples 的所有 profile 配置;对于 local 的仓库将会匹配所有的应用名以 local 开头的 Profiles。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
cloud:
config:
server:
git:
uri: https://gitee.com/peter/spring-cloud-config
search-paths: SC-BOOK-CONFIG
repos:
simple: https://gitee.com/peter/simple
special:
pattern: special*/dev*,*special*/dev*
uri: https://gitee.com/peter/spring-cloud-config-special
local:
pattern: local*
uri: /Users/peter/all_test/spring-cloud-config

关系型数据库的配置中心的实现

1. 基于 MySQL 的配置概述

Spring Cloud Config Server 默认提供了 JDBC 的方式连接 MySQL 数据库,整体的流程如下图,点击下载完整的案例代码。

config-server-mysql

2. 创建 Maven 父级 Pom 工程

在父工程里面配置好工程需要的父级依赖,目的是为了更方便管理与简化配置,具体 Maven 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
</parent>

<!-- 利用传递依赖,公共部分 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<!-- 管理依赖 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<!--注意:这里需要添加以下配置,否则可能会有各种依赖问题 -->
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>

3. 创建 Config Server 工程

创建 Config Server 的 Maven 工程,配置工程里的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

在 MySQL 中执行下述数据库脚本,创建对应的数据库和表,并插入对应的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 创建数据库
create database `spring-cloud-config` default character set utf8;

-- 当前数据库
use `spring-cloud-config`;

-- 创建类型表
CREATE TABLE `PROPERTIES` (
`ID` int(11) NOT NULL AUTO_INCREMENT,
`KEY` TEXT DEFAULT NULL,
`VALUE` TEXT DEFAULT NULL,
`APPLICATION` TEXT DEFAULT NULL,
`PROFILE` TEXT DEFAULT NULL,
`LABLE` TEXT DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

-- 插入数据
INSERT INTO `spring-cloud-config`.`PROPERTIES` (`ID`, `KEY`, `VALUE`, `APPLICATION`, `PROFILE`, `LABLE`) VALUES ('3', 'cn.springcloud.config', 'I am the mysql configuration file from dev environment.', 'config-client', 'dev', 'master');
INSERT INTO `spring-cloud-config`.`PROPERTIES` (`ID`, `KEY`, `VALUE`, `APPLICATION`, `PROFILE`, `LABLE`) VALUES ('4', 'cn.springcloud.config', 'I am the mysql configuration file from test environment.', 'config-client', 'test', 'master');
INSERT INTO `spring-cloud-config`.`PROPERTIES` (`ID`, `KEY`, `VALUE`, `APPLICATION`, `PROFILE`, `LABLE`) VALUES ('5', 'cn.springcloud.config', 'I am the mysql configuration file from prod environment.', 'config-client', 'prod', 'master');

添加 Config Server 需要的 application.yml 配置文件到工程中,其中 spring.cloud.config.server.jdbc.sql 是在调用时使用的 SQL,spring.profiles.active=jdbc 表示使用的激活方式是 JDBC,spring.cloud.refresh.refreshable=none 是用来解决 DataSource 循环依赖问题。若项目中需要激活其他 profile,那么可以指定多个,例如 spring.profiles.active=jdbc,dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server:
port: 8001

spring:
application:
name: config-server
cloud:
config:
server:
jdbc:
sql: SELECT `KEY`, `VALUE` FROM PROPERTIES WHERE application =? AND profile =? AND lable =?
label: master
refresh:
refreshable: none
profiles:
active: jdbc
datasource:
url: jdbc:mysql://127.0.0.1:3306/spring-cloud-config?useUnicode=true&characterEncoding=UTF-8
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver

logging:
level:
org.springframework.jdbc.core: DEBUG
org.springframework.jdbc.core.StatementCreatorUtils: Trace

创建 Config Server 的主启动类,增加 @EnableConfigServer 注解:

1
2
3
4
5
6
7
8
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {

public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

启动 Config Server 应用后,浏览器输入 http://127.0.0.1:8001/config-client/dev/master 访问 Config Server,接口返回的结果如下:

config-server-db-info

4. 创建 Config Client 工程

创建 Config Client 的 Maven 工程,配置工程里的 pom.xml 文件,需要引入 spring-cloud-config-client

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

创建 Config Server 的主启动类:

1
2
3
4
5
6
7
@SpringBootApplication
public class ConfigClientApplication {

public static void main(String[] args) {
SpringApplication.run(ConfigClientApplication.class, args);
}
}

为了更好地观察拉取到的 MySQL 上面的配置,这里需要创建一个 Controller 用于访问返回配置信息,同时还需要创建一个实体,用于注入远程配置上的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@ConfigurationProperties(prefix = "cn.springcloud")
public class ConfigProperties {

private String config;

public String getConfig() {
return config;
}

public void setConfig(String config) {
this.config = config;
}
}
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class ConfigController {

@Autowired
public ConfigProperties configProperties;

@GetMapping("/getConfigInfo")
public String getConfigInfo() {
return configProperties.getConfig();
}
}

添加 Config Client 需要的 application.yml 配置文件到工程中:

1
2
3
4
5
6
server:
port: 9090

spring:
application:
name: config-client

添加 Config Client 需要的 bootstrap.yml 配置文件到工程中:

1
2
3
4
5
6
7
spring:
cloud:
config:
name: config-client #需要从远程Git仓库读取的配置文件的名称,注意没有"yml"文件后缀,可以写多个,通过逗号隔开
profile: dev #本次访问的配置项
label: master #Git分支的名称
uri: http://127.0.0.1:8001 #Config Server的地址

5. 关于配置的刷新问题

手动刷新和配置自动刷新对于 DB 环境下是否同时支持呢?对于 DB 操作来说,在自动刷新方面,一般是做了界面化的配置和管理,当成功提交配置到 DB 后,会调用 Config Server 的 Spring Cloud Bus 刷新接口,这样就可以实现和 Git 的 WebHook — 样的提交绑定执行功能。

6. 测试结果

  1. 依次启动 config-server、config-client 应用
  2. 访问 http://127.0.0.1:9090/getConfigInfo,接口会返回 I am the git configuration file from dev environment,说明一切运行正常

非关系型数据库的配置中心的实现

基于 MongoDB 的配置概述

Spring Cloud Config Server 并没有提供 MongoDB 的存储方式,但是目前 Spring Cloud 已经收录了一个相关的孵化器。整体的流程如下图,由于篇幅有限,下面只给出 Config Server 工程的核心配置和代码,而 Config Client 工程与上面 MySQL 的示例基本上一样,这里不再累述。

config-server-mongodb

Config Server 工程的配置

Config Server 工程的 pom.xml 文件,添加 MongoDB 的依赖支持:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server-mongodb</artifactId>
<version>0.0.2.BUILD-SNAPSHOT</version>
</dependency>

Config Server 的主启动类,添加注解 @EnableMongoConfigServer

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableMongoConfigServer
public class MongoDbConfigServerApplication {

public static void main(String[] args) {
SpringApplication.run(MongoDbConfigServerApplication.class, args);
}
}

Config Server 工程里的 application.yml 文件

1
2
3
4
5
6
7
8
9
server:
port: 8001

spring:
application:
name: config-server
data:
mongodb:
uri: mongodb://localhost/springcloud

MongoDB 中的数据:

1
2
3
4
5
6
7
8
9
10
11
{
"label" : "master",
"profile" : "dev",
"source" : {
"cn" : {
"springcloud" : {
"config" : "I am the mongdb configuration file from dev environment. I will edit."
}
}
}
}

Config 功能扩展

客户端回退

客户端的回退机制,可以处理网络中断的情况,或者配置服务因维护而关闭的场景。当启用回退时,客户端适配器将 “缓存” 本地文件系统中的配置属性。要启用回退功能,只需指定存储缓存的位置即可;这个功能也称之为客户端高可用的一部分,也就是在服务端无法连接的情况下,客户端依然是可以用的,点击下载完整的案例代码。

1. 准备工作

由于下面的 Spring Cloud Config 使用 Git 作为存储方式,因此需要提前在 Git 远程仓库(Github、Gitlab)中创建对应的仓库,然后往仓库里 Push 三个配置文件,分别是 config-client-dev.yml、config-client-prod.yml、config-client-test.yml,配置文件的内容如下:

1
2
3
4
5
6
server:
port: 9001

cn:
springcloud:
config: I am the git configuration file from dev environment
1
2
3
4
5
6
server:
port: 9002

cn:
springcloud:
config: I am the git configuration file from prod environment
1
2
3
4
5
6
server:
port: 9003

cn:
springcloud:
config: I am the git configuration file from test environment

2. 创建 Config Client Fallback Autoconfig 工程

创建 Config Client Fallback Autoconfig 工程,配置工程里的 pom.xml 文件,其中 spring-security-rsa 依赖主要是用于当配置信息中存在敏感信息(如用户名密码)时,对敏感信息加密后再缓存在本地:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
</dependency>

创建 Config Client Fallback Autoconfig 工程里的 FallbackableConfigServicePropertySourceLocator 类,主要用来创建本地回退文件,也就是加载远程配置文件后在本地备份一份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Order(0)
public class FallbackableConfigServicePropertySourceLocator extends ConfigServicePropertySourceLocator {

private boolean fallbackEnabled;
private String fallbackLocation;

@Autowired(required = false)
TextEncryptor textEncryptor;

public FallbackableConfigServicePropertySourceLocator(ConfigClientProperties defaultProperties, String fallbackLocation) {
super(defaultProperties);
this.fallbackLocation = fallbackLocation;
this.fallbackEnabled = !StringUtils.isEmpty(fallbackLocation);
}

@Override
public PropertySource<?> locate(Environment environment) {
PropertySource<?> propertySource = super.locate(environment);
if (fallbackEnabled) {
if (propertySource != null) {
storeLocally(propertySource);
}
}
return propertySource;
}

private void storeLocally(PropertySource propertySource) {
StringBuilder sb = new StringBuilder();
CompositePropertySource source = (CompositePropertySource) propertySource;
for (String propertyName : source.getPropertyNames()) {
Object value = source.getProperty(propertyName);
if (textEncryptor != null)
value = "{cipher}" + textEncryptor.encrypt(String.valueOf(value));
sb.append(propertyName).append("=").append(value).append("\n");
}

System.out.println("file contents : " + sb.toString());
saveFile(sb.toString());
}

private void saveFile(String contents) {
BufferedWriter output = null;
File file = new File(fallbackLocation + File.separator + ConfigServerBootstrap.FALLBACK_FILE_NAME);
try {
if (!file.exists()) {
file.createNewFile();
}
output = new BufferedWriter(new FileWriter(file));
output.write(contents);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (output != null) {
try {
output.close();
} catch (IOException e) {
System.out.print("Error" + e.getMessage());
}
}
}
}
}

创建 Config Client Fallback Autoconfig 工程的自动配置类,添加相关注解,使其在 Spring Boot 启动的时候进行加载。其中 spring.cloud.config.fallbackLocation 是指回退配置文件所在的目录路径,file:${spring. cloud.config.fallbackLocation:}/fallback.properties 是指回退配置文件的完整路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 客户端自动配置依赖启动
*/
@Configuration
@EnableConfigurationProperties
@PropertySource(value = {"config-client.properties", "file:${spring.cloud.config.fallbackLocation:}/fallback.properties"}, ignoreResourceNotFound = true)
public class ConfigServerBootstrap {

public static final String FALLBACK_FILE_NAME = "fallback.properties";

@Autowired
private ConfigurableEnvironment environment;

@Value("${spring.cloud.config.fallbackLocation:}")
private String fallbackLocation;

@Bean
public ConfigClientProperties configClientProperties() {
ConfigClientProperties clientProperties = new ConfigClientProperties(this.environment);
clientProperties.setEnabled(false);
return clientProperties;
}

@Bean
public FallbackableConfigServicePropertySourceLocator fallbackableConfigServicePropertySourceLocator() {
ConfigClientProperties client = configClientProperties();
FallbackableConfigServicePropertySourceLocator fallbackableConfigServicePropertySourceLocator
= new FallbackableConfigServicePropertySourceLocator(client, fallbackLocation);
return fallbackableConfigServicePropertySourceLocator;
}
}

创建 Config Client Fallback Autoconfig 工程里的 /src/main/resources/config-client.properties 配置文件:

1
spring.cloud.config.enabled=false

创建 Config Refresh Fallback Autoconfig 工程里 /src/main/resources/META-INF/spring.factories 配置文件,添加上面的自动配置类:

1
2
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
com.springcloud.study.fallback.config.ConfigServerBootstrap

3. 创建 Config Client 工程

创建 Config Client 的 Maven 工程,配置工程里的 pom.xml 文件,需要引入上面的 config-client-fallback-autoconfig

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-client</artifactId>
</dependency>
<dependency>
<groupId>com.springcloud.study</groupId>
<artifactId>config-client-fallback-autoconfig</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

创建 Config Client 的主启动类:

1
2
3
4
5
6
7
@SpringBootApplication
public class ConfigClientApplication {

public static void main(String[] args) {
SpringApplication.run(ConfigClientApplication.class, args);
}
}

为了更好地观察拉取到的 Git 上面的配置,这里需要创建一个 Controller 用于访问返回配置信息,同时还需要创建一个实体,用于注入远程配置上的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@ConfigurationProperties(prefix = "cn.springcloud")
public class ConfigProperties {

private String config;

public String getConfig() {
return config;
}

public void setConfig(String config) {
this.config = config;
}
}
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class ConfigController {

@Autowired
public ConfigProperties configProperties;

@GetMapping("/getConfigInfo")
public String getConfigInfo() {
return configProperties.getConfig();
}
}

添加 Config Client 需要的 application.yml 配置文件到工程中:

1
2
3
spring:
application:
name: config-client

添加 Config Client 需要的 bootstrap.yml 配置文件到工程中,fallbackLocation 指定了回退文件的路径:

1
2
3
4
5
6
7
8
spring:
cloud:
config:
name: config-client #需要从远程Git仓库读取的配置文件的名称,注意没有"yml"文件后缀,可以写多个,通过逗号隔开
profile: dev #本次访问的配置项
label: master #Git分支的名称
uri: http://127.0.0.1:8001 #Config Server的地址
fallbackLocation: /tmp/config/config-client-dev/ #回退文件的路径

4. 创建 Config Server 工程

创建 Config Server 的 Maven 工程,配置工程里的 pom.xml 文件,需要引入 spring-cloud-config-server

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

创建 Config Server 的主启动类,增加 @EnableConfigServer 注解:

1
2
3
4
5
6
7
8
@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {

public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}

添加 Config Server 需要的 application.yml 配置文件到工程中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 8001

spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: git@github.com:xxxxx/spring-cloud-config-study-repo.git
search-paths: spring-cloud-config-study-repo/
strictHostKeyChecking: false
private_key_file: /root/.ssh/id_rsa.pub
label: master

5. 测试结果

  1. 依次启动 config-server、config-client 应用
  2. 访问 http://127.0.0.1:9001/getConfigInfo 后观察返回的配置信息,与此同时查看是否在目录 /tmp/config/config-client-dev/ 下成功创建了回退文件 fallback.properties
  3. 关闭 config-server、config-client 应用,然后单独启动 config-client 应用;观察在不启动 config-server 的情况下,config-client 应用是否能正常启动
  4. 若 config-client 应用单独启动成功,config-client 应用会先尝试去连接 config-server,当连接失败后,会加载本地的回退配置文件,此时控制台输出的日志信息如下:
1
2
3
4
c.c.c.ConfigServicePropertySourceLocator : Fetching config from server at : http://127.0.0.1:8001
c.c.c.ConfigServicePropertySourceLocator : Connect Timeout Exception on Url - http://127.0.0.1:8001. Will be trying the next url if available
c.c.c.ConfigServicePropertySourceLocator : Could not locate PropertySource: I/O error on GET request for "http://127.0.0.1:8001/config-client/dev/master": 拒绝连接; nested exception is java.net.ConnectException: 拒绝连接
c.s.study.ConfigClientApplication : No active profile set, falling back to default profiles: default

客户端的安全认证机制 JWT

Spring Cloud Config 客户端支持使用 JWT 身份验证方法代替标准的基本身份验证,这种方式需要对服务端和客户端都要改造,点击下载完整的案例代码,具体的验证步骤如下:

  • 客户端向服务端负载授权的 RestController 发送请求,并且带上用户名和密码
  • 服务端成功验证用户名和密码后,返回 Jwt Token
  • 客户端加载服务端的配置信息,需要在 Header 中带上 Token 令牌进行认证

i. 准备工作

本示例用到上面的 “客户端回退” 示例中 Git 仓库里的配置文件,包括 config-client-dev.yml、config-client-prod.yml、config-client-test.yml。

ii. 创建 Config Client Jwt 工程

创建 Config Client Jwt 工程,配置工程里的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>

创建 Config Client Jwt 工程的自动配置类,@PostConstruct 注解是执行是在 Servlet 构造函数和 init() 方法执行之间,也就是说在容器启动过程中会创建一个 RestTemplate 对象,将用户名和密码发送到 Config Server 端进行认证;认证成功会返回 Token,如果认证过程中用户名或者是密码错误,则将返回一个 401 认证失败的错误码。其中 ${spring.cloud.config.usemame}${spring.cloud.config.password} 等参数是配置在客户端的,这里需要创建 ConfigServicePropertySourceLocator 这个 Bean 并且自定义一个 RestTemplate 对象需要带上 Token 信息,这就是代码中的 customRestTemplate 方法。还需要定义一个 ClientHttpRequestlnterceptor 接口的实现类,也就是代码中的 GenericRequestHeaderInterceptor 类,主要用于拦截发送到 Config Server 获取配置信息的请求,将 Token 信息添加到 HttpServletRequest 的 Headers 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
@Configuration
@Order(Ordered.LOWEST_PRECEDENCE)
public class ConfigClientBootstrapConfiguration {

private static Log logger = LogFactory.getLog(ConfigClientBootstrapConfiguration.class);

@Value("${spring.cloud.config.username}")
private String jwtUserName;

@Value("${spring.cloud.config.password}")
private String jwtPassword;

@Value("${spring.cloud.config.endpoint}")
private String jwtEndpoint;

private String jwtToken;

@Autowired
private ConfigurableEnvironment environment;

@PostConstruct
public void init() {
RestTemplate restTemplate = new RestTemplate();
LoginRequest loginBackend = new LoginRequest();
loginBackend.setUsername(jwtUserName);
loginBackend.setPassword(jwtPassword);

String serviceUrl = jwtEndpoint;
Token token;

try {
token = restTemplate.postForObject(serviceUrl, loginBackend, Token.class);
if (token.getToken() == null) {
throw new Exception();
}

// 设置token
setJwtToken(token.getToken());
} catch (Exception e) {
e.printStackTrace();
}
}

public String getJwtToken() {
return jwtToken;
}

public void setJwtToken(String jwtToken) {
this.jwtToken = jwtToken;
}

@Bean
public ConfigServicePropertySourceLocator configServicePropertySourceLocator(ConfigClientProperties configClientProperties) {
ConfigServicePropertySourceLocator configServicePropertySourceLocator = new ConfigServicePropertySourceLocator(configClientProperties);
configServicePropertySourceLocator.setRestTemplate(customRestTemplate());
return configServicePropertySourceLocator;
}

@Bean
public ConfigClientProperties configClientProperties() {
ConfigClientProperties clientProperties = new ConfigClientProperties(this.environment);
clientProperties.setEnabled(false);
return clientProperties;
}

/**
* 自定义restTemplate ,在发送的时候带上token
*
* @return
*/
private RestTemplate customRestTemplate() {
Map<String, String> headers = new HashMap<>();
headers.put("token", "Bearer:" + jwtToken);
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setReadTimeout((60 * 1000 * 3) + 5000);
RestTemplate template = new RestTemplate(requestFactory);
if (!headers.isEmpty()) {
template.setInterceptors(
Arrays.<ClientHttpRequestInterceptor>asList(new GenericRequestHeaderInterceptor(headers)));
}
return template;
}

/**
* 客户端请求过滤器
*/
public static class GenericRequestHeaderInterceptor implements ClientHttpRequestInterceptor {

private final Map<String, String> headers;

public GenericRequestHeaderInterceptor(Map<String, String> headers) {
this.headers = headers;
}

/**
* 请求之前操作的方法
*
* @param httpRequest
* @param bytes
* @param clientHttpRequestExecution
* @return
* @throws IOException
*/
@Override
public ClientHttpResponse intercept(HttpRequest httpRequest, byte[] bytes, ClientHttpRequestExecution clientHttpRequestExecution) throws IOException {
headers.entrySet().stream().forEach(header -> {
httpRequest.getHeaders().add(header.getKey(), header.getValue());
});
return clientHttpRequestExecution.execute(httpRequest, bytes);
}
}
}

创建 Config Client Jwt 工程里的实体类,用于传递用户信息:

1
2
3
4
5
6
7
8
9
10
11
12
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LoginRequest implements Serializable {

@JsonProperty
private String username;

@JsonProperty
private String password;

//省略getter、setter方法
}
1
2
3
4
5
6
7
8
9
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Token implements Serializable {

@JsonProperty
private String token;

//省略getter、setter方法
}

在 Config Client Jwt 工程里创建 /src/main/resources/META-INF/spring.factories 配置文件,添加上面的自动配置类:

1
2
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
com.springcloud.study.config.ConfigClientBootstrapConfiguration

iii. 创建 Config Server 工程

创建 Config Server 工程,配置工程里的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.7</version>
</dependency>

创建 Config Server 工程里的 JwtAuthenticationRequest 实体类,用于传递用户名和密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JwtAuthenticationRequest implements Serializable {

private String username;
private String password;

public JwtAuthenticationRequest() {
super();
}

public JwtAuthenticationRequest(String username, String password) {
this.setUsername(username);
this.setPassword(password);
}

//省略getter、setter方法
}

创建 Config Server 工程里的 JwtAuthenticationResponse 实体类,用于返回 Token 信息:

1
2
3
4
5
6
7
8
9
10
public class JwtAuthenticationResponse implements Serializable {

private final String token;

public JwtAuthenticationResponse(String token) {
this.token = token;
}

//省略getter、setter方法
}

创建 Config Server 工程里的 JwtUser 实体类,用于返回 JWT 用户认证信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class JwtUser implements UserDetails {

private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;

public JwtUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}

@Override
public String getUsername() {
return username;
}

@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}

@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}

@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}

@JsonIgnore
@Override
public String getPassword() {
return password;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public boolean isEnabled() {
return true;
}

@Override
public String toString() {
return "JwtUser [username=" + username + ", password=" + password + ", authorities=" + authorities + "]";
}
}

创建 Config Server 工程里的 JWT Token 认证过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class JwtAuthenticationTokenFilter extends UsernamePasswordAuthenticationFilter {

@Autowired
private UserDetailsService userDetailsService;

@Autowired
private JwtTokenUtil jwtTokenUtil;

private final String tokenHeader = "token";

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

HttpServletRequest httpRequest = (HttpServletRequest) request;
String authToken = httpRequest.getHeader(tokenHeader);
String username = jwtTokenUtil.getUsernameFromToken(authToken);

if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}

创建 Config Server 工程里的 JWT 工具类,主要用于根据传递过来的用户信息生成 JWT 的 Token,或者是验证请求的 Token 是否合法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
@Component
public class JwtTokenUtil implements Serializable {

private static final long serialVersionUID = -8652360919584431721L;

private static final String CLAIM_KEY_USERNAME = "sub";
private static final String CLAIM_KEY_AUDIENCE = "audience";
private static final String CLAIM_KEY_CREATED = "created";

private static final String AUDIENCE_UNKNOWN = "unknown";
private static final String AUDIENCE_WEB = "web";

private Key secret = MacProvider.generateKey();
private Long expiration = (long) 120; // 2 minutes

/**
* 生成token
*
* @param userDetails
* @return
*/
public String generateToken(JwtUser userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_AUDIENCE, AUDIENCE_WEB);
claims.put(CLAIM_KEY_CREATED, new Date().getTime() / 1000);

return generateToken(claims);
}

/**
* jwt 实际生成token
*
* @param claims
* @return
*/
private String generateToken(Map<String, Object> claims) {
return Jwts.builder().setClaims(claims).setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret).compact();
}

private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + expiration * 1000);
}

public String getUsernameFromToken(String token) {
if (token == null) {
return null;
}

String username;
try {
final Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}

return username;
}

private Claims getClaimsFromToken(String token) {
Claims claims;

final String tokenClean = token.substring(7); // remove "Bearer:"
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(tokenClean).getBody();
} catch (Exception e) {
claims = null;
}

return claims;
}

/**
* 校验token的合法性
*
* @param token
* @param userDetails
* @return
*/
public Boolean validateToken(String token, UserDetails userDetails) {
JwtUser user = (JwtUser) userDetails;
final String username = getUsernameFromToken(token);

return (username.equals(user.getUsername()) && !isTokenExpired(token));
}

private Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}

public Date getExpirationDateFromToken(String token) {
Date expiration;
try {
final Claims claims = getClaimsFromToken(token);
expiration = claims.getExpiration();
} catch (Exception e) {
expiration = null;
}
return expiration;
}
}

创建 Config Server 工程里的 JWT 认证端点类,主要用于在认证过程中,若认证未能通过直接返回 401 状态码:

1
2
3
4
5
6
7
8
9
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// 没有认证通过将添加401
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}

创建 Config Server 工程里的账号验证类,主要用于客户端的验证用户名和密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class MemberServiceImpl implements UserDetailsService {

private static final PasswordEncoder BCRYPT = new BCryptPasswordEncoder();

@Value("${spring.security.user.name}")
private String hardcodedUser;

@Value("${spring.security.user.password}")
private String password;

@Override
public JwtUser loadUserByUsername(String username) throws UsernameNotFoundException {
// 对密码进行加密
String hardcodedPassword = BCRYPT.encode(password);
if (username.equals(hardcodedUser) == false) {
throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username));
} else {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_USER");
List<GrantedAuthority> grantedAuthorityList = new ArrayList<GrantedAuthority>();
grantedAuthorityList.add(simpleGrantedAuthority);
return new JwtUser(hardcodedUser, hardcodedPassword, grantedAuthorityList);
}
}
}

创建 Config Server 工程的 WebAuthenticationDetailsSourceImpl 类,用于将传递过来的对象数据封装到 JwtAuthenticationRequest 里面,该类负责将数据封装成 JSON 格式后返回给客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
public class WebAuthenticationDetailsSourceImpl implements AuthenticationDetailsSource<HttpServletRequest, JwtAuthenticationRequest> {

@Override
public JwtAuthenticationRequest buildDetails(HttpServletRequest request) {
Gson gson = new Gson();
String json = new String();
String output = new String();
BufferedReader br;
StringBuffer buffer = new StringBuffer(16384);
JwtAuthenticationRequest jwtAuthenticationRequest = new JwtAuthenticationRequest();
try {
br = new BufferedReader(new InputStreamReader(request.getInputStream()));
while ((output = br.readLine()) != null) {
buffer.append(output);
}

json = buffer.toString();
jwtAuthenticationRequest = gson.fromJson(json, JwtAuthenticationRequest.class);
} catch (IOException e) {
e.printStackTrace();
}
return jwtAuthenticationRequest;
}
}

创建 Config Server 工程的 AuthenticationRestController 类,主要用于颁发 Token 给客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RestController
public class AuthenticationRestController {

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private JwtTokenUtil jwtTokenUtil;

@Autowired
private MemberServiceImpl userDetailsService;

@Autowired
private WebAuthenticationDetailsSourceImpl webAuthenticationDetailsSource;

@RequestMapping(value = "/auth", method = RequestMethod.POST)
public ResponseEntity<?> createAuthenticationToken(HttpServletRequest request) {
JwtAuthenticationRequest jwtAuthenticationRequest = webAuthenticationDetailsSource.buildDetails(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(jwtAuthenticationRequest.getUsername(), jwtAuthenticationRequest.getPassword());
authToken.setDetails(jwtAuthenticationRequest);
Authentication authenticate = authenticationManager.authenticate(authToken);
SecurityContextHolder.getContext().setAuthentication(authenticate);

JwtUser userDetails = userDetailsService.loadUserByUsername(jwtAuthenticationRequest.getUsername());
final String token = jwtTokenUtil.generateToken(userDetails);

return ResponseEntity.ok(new JwtAuthenticationResponse(token));
}
}

创建 Config Server 工程的 SecurityConfig 类,主要作用是进行安全认证和 Token 的过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private JwtAuthenticationEntryPoint unAuthorizedHandler;

@Autowired
private WebAuthenticationDetailsSourceImpl webAuthenticationDetailsSource;

@Bean
@ConditionalOnMissingBean(AuthenticationManager.class)
public UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) throws Exception {

UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter = new UsernamePasswordAuthenticationFilter();
usernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManager);
usernamePasswordAuthenticationFilter.setAuthenticationDetailsSource(webAuthenticationDetailsSource);
return usernamePasswordAuthenticationFilter;
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

@Bean
public JwtAuthenticationTokenFilter authenticationTokenFilter() throws Exception {
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter = new JwtAuthenticationTokenFilter();
jwtAuthenticationTokenFilter.setAuthenticationManager(authenticationManager());
jwtAuthenticationTokenFilter.setAuthenticationDetailsSource(webAuthenticationDetailsSource);
return jwtAuthenticationTokenFilter;
}

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.exceptionHandling().authenticationEntryPoint(unAuthorizedHandler)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/").permitAll()
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated().and().formLogin()
.authenticationDetailsSource(webAuthenticationDetailsSource)
.permitAll();

// 添加自定义的jwt安全过滤的filter
httpSecurity.addFilterBefore(authenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
httpSecurity.headers().cacheControl();
}
}

iiii. 创建 Config Client 工程

这里的 Config Client 工程与上面 “客户端回退” 示例中的 Config Client 工程的代码一致,直接拷贝一份即可,这里不再累述。

Config Client 里的 pom.xml 文件,引入上面的 config-client-jwt 依赖:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-client</artifactId>
</dependency>
<dependency>
<groupId>com.springcloud.study</groupId>
<artifactId>config-client-jwt</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

Config Client 里的 application.yml 配置文件:

1
2
3
spring:
application:
name: config-client

Config Client 里的 bootstrap.yml 配置文件,其中 passwordusername 是 Config Server 端配置需要的认证用户信息,endpoint 是一个 Config Server 访问验证授权的地址:

1
2
3
4
5
6
7
8
9
10
11
spring:
cloud:
config:
name: config-client #需要从远程Git仓库读取的配置文件的名称,注意没有"yml"文件后缀,可以写多个,通过逗号隔开
profile: dev #本次访问的配置项
label: master #Git分支的名称
uri: http://127.0.0.1:8001 #Config Server的地址
username: admin
password: 123456
enabled: false
endpoint: http://localhost:8001/auth #指定JWT的认证地址

iiiii. 测试结果

  1. 依次启动 config-server、config-client 应用
  2. 访问 http://127.0.0.1:9090/getConfigInfo,接口会返回 I am the git configuration file from dev environment,说明一切运行正常
  3. config-client 特意填写错误的账号信息,然后重新启动 config-client 应用,观察控制台是否会出现 401 授权失败的错误

下篇 - Config 入门教程(高级篇)