配置管理

在微服务的世界里,我们已经攻克了不少难关:

  • 微服务之间的远程调用顺畅了
  • 服务能自动注册和发现,不用手动记地址了
  • 请求路由和负载均衡也搞定了,流量分配更合理
  • 登录用户的信息能在服务间顺畅传递,权限校验不再头疼

但新的问题又冒了出来,就像清理完一批杂草又长出新的:

  • 网关的路由规则写死在配置文件里,改个路径都得重启服务,太影响用户体验了
  • 有些业务参数,比如购物车能装多少商品,也硬编码在配置里,改一次就得重新打包、重启,效率低得让人着急
  • 每个微服务里都有一堆重复的配置,像数据库连接、日志格式这些,改一处得同步改好几个地方,维护起来头都大了

还好,这些问题都能靠统一配置管理器解决。而 Nacos 这工具特给力,不仅能当服务注册中心,还自带配置管理功能 —— 就像一个既能管人员考勤又能管物资分配的万能助手。

有了 Nacos,微服务的共享配置可以统一存放在它那。在 Nacos 控制台改完配置,它会主动把新配置推送给相关的微服务,而且不用重启服务就能生效,这就是神奇的 “配置热更新”。网关的路由本质上也是一种配置,自然也能通过这功能实现 “动态路由”,改路由规则时网关照样跑,不用停下来。

1. 配置共享

微服务之间有很多配置是重复的,比如连接数据库的参数、日志的输出格式,要是每个服务都单独配一份,不仅麻烦还容易出错。我们可以把这些重复的配置抽出来,放到 Nacos 里统一管理,就像把大家都要用的工具集中放在一个工具箱里,谁用谁拿。具体分两步:

  • 第一步:在 Nacos 里添加这些共享配置
  • 第二步:让各个微服务去 Nacos 拉取这些配置

1.1. 添加共享配置

我们以购物车服务(cart-service)为例,看看哪些配置是重复出现、可以抽出来的:

  • 首先是 jdbc 相关配置:连接数据库的 URL、驱动类、用户名密码等,几乎每个服务都得用
  • 然后是日志配置:日志的级别、输出格式、保存路径等,一般整个系统得保持一致
  • 还有 swagger 以及 OpenFeign 的配置:接口文档的基本信息、远程调用的设置等,很多服务都需要

接下来,我们就在 Nacos 控制台一个一个添加这些共享配置。

先处理 jdbc 相关配置:在 Nacos 的 “配置管理 → 配置列表” 里点击 “+” 号新建配置。

在弹出的表单里填写相关信息,其中详细的配置内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
datasource:
url: jdbc:mysql://${hm.db.host:192.168.150.101}:${hm.db.port:3306}/${hm.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${hm.db.un:root}
password: ${hm.db.pw:123}
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto

这里的 jdbc 相关参数没有写死,而是用了一种灵活的方式,比如:

  • 数据库 ip:通过 ${hm.db.host:192.168.150.101} 设置,意思是如果没特别指定 hm.db.host,就用 192.168.150.101 这个默认值,要是想换个 ip,指定 hm.db.host 就行
  • 数据库端口:${hm.db.port:3306} 也是同样的道理,默认 3306,能通过 hm.db.port 修改
  • 数据库 database:用 ${hm.db.database} 表示,没有默认值,得由具体的服务自己指定,比如购物车服务用 hm-cart,用户服务用 hm-user

这样一来,这个配置模板既能通用,又能满足不同服务的个性化需求,特别方便。

然后是统一的日志配置,给它起名叫 shared-log.yaml,配置内容如下:

1
2
3
4
5
6
7
logging:
level:
com.hmall: debug
pattern:
dateformat: HH:mm:ss:SSS
file:
path: "logs/${spring.application.name}"

再来是统一的 swagger 配置,命名为 shared-swagger.yaml,配置内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
knife4j:
enable: true
openapi:
title: ${hm.swagger.title:黑马商城接口文档}
description: ${hm.swagger.description:黑马商城接口文档}
email: ${hm.swagger.email:zhanghuyi@itcast.cn}
concat: ${hm.swagger.concat:虎哥}
url: https://www.itcast.cn
version: v1.0.0
group:
default:
group-name: default
api-rule: package
api-rule-resources:
- ${hm.swagger.package}

这里的 swagger 相关配置也没写死,举个例子:

  • title:接口文档的标题,用 ${hm.swagger.title} 代替,以后哪个服务想用自己的标题,手动指定就行
  • email:联系人邮箱,${hm.swagger.email:zhanghuyi@itcast.cn} 表示默认是 zhanghuyi@itcast.cn,要是想换,用 hm.swagger.email 覆盖掉就行

1.2. 拉取共享配置

接下来,微服务要去拉取 Nacos 里的共享配置,然后和自己本地的 application.yaml 配置合并到一起,完成项目的初始化。

不过这里有个小问题:读取 Nacos 配置是在 SpringCloud 上下文(ApplicationContext)初始化的引导阶段进行的,这时候 SpringBoot 上下文还没开始初始化,本地的 application.yaml 文件都还没读呢 —— 也就是说,这时候微服务根本不知道 Nacos 在哪,怎么去拉配置呢?

别担心,SpringCloud 早就想到了这个问题。它在初始化上下文的时候,会先读一个叫 bootstrap.yaml(或者 bootstrap.properties)的文件。所以我们只要把 Nacos 的地址配置到 bootstrap.yaml 里,微服务在引导阶段就能知道 Nacos 在哪,顺利去拉配置了。

所以,微服务整合 Nacos 配置管理的步骤是这样的:

1)引入依赖:
在 cart-service 模块里引入这些依赖:

1
2
3
4
5
6
7
8
9
10
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

2)新建 bootstrap.yaml
在 cart-service 的 resources 目录下新建一个 bootstrap.yaml 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
application:
name: cart-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.150.101 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置

3)修改 application.yaml
因为一些配置挪到 bootstrap.yaml 里了,所以 application.yaml 得改一改:

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8082
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
hm:
swagger:
title: 购物车服务接口文档
package: com.hmall.cart.controller
db:
database: hm-cart

重启服务后,会发现所有配置都正常生效了。

2. 配置热更新

很多业务相关的参数,将来可能会根据实际情况随时调整。比如购物车业务,购物车能装的商品数量有个上限,默认是 10 个,现在这数值是写死在代码里的。

我们应该把这个数值配置在配置文件里,方便以后修改。但问题是,就算写在配置文件里,改完之后还是得重新打包、重启服务才能生效,太麻烦了。能不能不改代码、不重启服务,改了配置就能直接生效呢?

这就得用到 Nacos 的配置热更新能力了,具体分两步:

  • 第一步:在 Nacos 里添加这个配置
  • 第二步:在微服务里读取这个配置

2.1. 添加配置到 Nacos

首先,我们在 Nacos 里添加一个配置文件,把购物车的上限数量放进去。

要注意这个文件的 dataId 格式:[服务名]-[spring.active.profile].[后缀名]。这个文件名由三部分组成:

  • 服务名:我们这里是购物车服务,所以是 cart-service
  • spring.active.profile:就是 spring boot 里的 spring.active.profile,要是省略的话,所有环境都能共享这个配置
  • 后缀名:比如 yaml

我们这里直接用 cart-service.yaml 这个名称,这样不管是 dev 环境还是 local 环境,购物车服务都能用上这个配置。

配置内容如下:

1
2
3
hm:
cart:
maxAmount: 1 # 购物车商品数量上限

提交配置后,在 Nacos 控制台就能看到这个新添加的配置了。

2.2. 配置热更新

接着,我们要在微服务里读取这个配置,实现配置热更新。

在 cart-service 里新建一个属性读取类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
package com.hmall.cart.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
private Integer maxAmount;
}

然后,在业务代码里使用这个属性加载类。

我们来测试一下:先往购物车里添加多个商品,会发现超过上限就加不进去了。

接着,我们在 Nacos 控制台把购物车上限改成 5,不用重启服务,再去测试购物车功能,会发现能成功加入 5 个商品了 —— 配置热更新生效了,就是这么方便!

3. 动态路由

网关的路由配置有点特殊,它是在项目启动的时候,由 org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator 加载到内存里的路由表里(就是一个 Map),一旦加载完成就固定不变了,也不会去监听路由有没有变更。所以上一节学的配置热更新没法直接用来更新路由。

那怎么办呢?我们得自己想办法:监听 Nacos 的配置变更,然后手动把最新的路由更新到路由表里。这里有两个难点:

  • 怎么监听 Nacos 的配置变更?
  • 怎么把路由信息更新到路由表里?

3.1. 监听 Nacos 配置变更

Nacos 官网提供了手动监听配置变更的 SDK,地址是:https://nacos.io/zh-cn/docs/sdk.html

如果想让 Nacos 主动推送配置变更,可以用它的动态监听配置接口来实现,就是这个方法:

1
public void addListener(String dataId, String group, Listener listener)

这个方法的参数说明如下:

参数名 参数类型 描述
dataId string 配置 ID,得保证全局唯一,只能用英文字符和 “.”、“:”、“-”、“_” 这 4 种特殊字符,不能超过 256 字节。
group string 配置分组,一般用默认的 DEFAULT_GROUP 就行。
listener Listener 监听器,配置变更的时候会触发这个监听器里的回调函数。

示例代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String serverAddr = "{serverAddr}";
String dataId = "{dataId}";
String group = "{group}";
// 1.创建ConfigService,连接Nacos
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
// 2.读取配置
String content = configService.getConfig(dataId, group, 5000);
// 3.添加配置监听器
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
// 配置变更的通知处理
System.out.println("recieve1:" + configInfo);
}
@Override
public Executor getExecutor() {
return null;
}
});

这里的核心步骤有两步:

  • 创建 ConfigService,目的是连接到 Nacos
  • 添加配置监听器,写好配置变更时的处理逻辑

不过我们项目里用了 spring-cloud-starter-alibaba-nacos-config,它会自动帮我们装配好 ConfigService,就在 com.alibaba.cloud.nacos.NacosConfigAutoConfiguration 里。而 NacosConfigManager 是负责管理 Nacos 的 ConfigService 的,所以只要拿到 NacosConfigManager,就等于拿到了 ConfigService,第一步就搞定了。

第二步是编写监听器。虽然官方 SDK 里 ConfigService 的 addListener 方法能用,但项目第一次启动的时候,不仅要添加监听器,还得读取配置,所以建议用这个 API:

1
2
3
4
5
6
String getConfigAndSignListener(
String dataId, // 配置文件id
String group, // 配置组,用默认的
long timeoutMs, // 读取配置的超时时间
Listener listener // 监听器
) throws NacosException;

这个方法既能配置监听器,又能根据 dataId 和 group 读取配置并返回。这样我们就能在项目启动时先更新一次路由,之后配置变了,监听器收到通知再更新路由。

3.2. 更新路由

更新路由要用到 org.springframework.cloud.gateway.route.RouteDefinitionWriter 这个接口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.springframework.cloud.gateway.route;

import reactor.core.publisher.Mono;

/**
* @author Spencer Gibb
*/
public interface RouteDefinitionWriter {
/**
* 更新路由到路由表,如果路由id重复,就会覆盖旧的路由
*/
Mono<Void> save(Mono<RouteDefinition> route);
/**
* 根据路由id删除某个路由
*/
Mono<Void> delete(Mono<String> routeId);

}

这里说的路由,也就是 RouteDefinition,它包含这些常见字段:

  • id:路由 id
  • predicates:路由匹配规则
  • filters:路由过滤器
  • uri:路由目的地

将来我们存在 Nacos 里的配置也得符合这个对象结构,我们用 JSON 来保存,格式如下:

1
2
3
4
5
6
7
8
9
{
"id": "item",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
}],
"filters": [],
"uri": "lb://item-service"
}

这个 JSON 配置就相当于:

1
2
3
4
5
6
7
8
spring:
cloud:
gateway:
routes:
- id: item
uri: lb://item-service
predicates:
- Path=/items/**,/search/**

好了,需要用到的 SDK 都清楚了。

3.3. 实现动态路由

首先,我们在网关 gateway 里引入依赖:

1
2
3
4
5
6
7
8
9
10
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--加载bootstrap-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

然后在网关 gateway 的 resources 目录下创建 bootstrap.yaml 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
spring:
application:
name: gateway
cloud:
nacos:
server-addr: 192.168.150.101
config:
file-extension: yaml
shared-configs:
- dataId: shared-log.yaml # 共享日志配置

接着,修改 gateway 的 resources 目录下的 application.yml,把之前的路由配置删掉,最后内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8080 # 端口
hm:
jwt:
location: classpath:hmall.jks # 秘钥地址
alias: hmall # 秘钥别名
password: hmall123 # 秘钥文件密码
tokenTTL: 30m # 登录有效期
auth:
excludePaths: # 无需登录校验的路径
- /search/**
- /users/login
- /items/**

然后,在 gateway 里定义配置监听器,代码如下:

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
package com.hmall.gateway.route;

import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.nacos.NacosConfigManager;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import com.hmall.common.utils.CollUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.gateway.route.RouteDefinitionWriter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import javax.annotation.PostConstruct;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;

@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {

private final RouteDefinitionWriter writer;
private final NacosConfigManager nacosConfigManager;

// 路由配置文件的id和分组
private final String dataId = "gateway-routes.json";
private final String group = "DEFAULT_GROUP";
// 保存更新过的路由id
private final Set<String> routeIds = new HashSet<>();

@PostConstruct
public void initRouteConfigListener() throws NacosException {
// 1.注册监听器并首次拉取配置
String configInfo = nacosConfigManager.getConfigService()
.getConfigAndSignListener(dataId, group, 5000, new Listener() {
@Override
public Executor getExecutor() {
return null;
}

@Override
public void receiveConfigInfo(String configInfo) {
updateConfigInfo(configInfo);
}
});
// 2.首次启动时,更新一次配置
updateConfigInfo(configInfo);
}

private void updateConfigInfo(String configInfo) {
log.debug("监听到路由配置变更,{}", configInfo);
// 1.反序列化
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 2.更新前先清空旧路由
// 2.1.清除旧路由
for (String routeId : routeIds) {
writer.delete(Mono.just(routeId)).subscribe();
}
routeIds.clear();
// 2.2.判断是否有新的路由要更新
if (CollUtils.isEmpty(routeDefinitions)) {
// 无新路由配置,直接结束
return;
}
// 3.更新路由
routeDefinitions.forEach(routeDefinition -> {
// 3.1.更新路由
writer.save(Mono.just(routeDefinition)).subscribe();
// 3.2.记录路由id,方便将来删除
routeIds.add(routeDefinition.getId());
});
}
}

重启网关后,随便访问一个接口,比如 http://localhost:8080/search/list?pageNo=1&pageSize=1,会发现是 404,访问不了,这是因为还没配置路由呢。

接下来,我们直接在 Nacos 控制台添加路由,路由文件名叫 gateway-routes.json,类型是 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[
{
"id": "item",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
}],
"filters": [],
"uri": "lb://item-service"
},
{
"id": "cart",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/carts/**"}
}],
"filters": [],
"uri": "lb://cart-service"
},
{
"id": "user",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/users/**", "_genkey_1":"/addresses/**"}
}],
"filters": [],
"uri": "lb://user-service"
},
{
"id": "trade",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/orders/**"}
}],
"filters": [],
"uri": "lb://trade-service"
},
{
"id": "pay",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/pay-orders/**"}
}],
"filters": [],
"uri": "lb://pay-service"
}
]

不用重启网关,等个几秒钟再访问刚才的地址,会发现网关能成功路由了 —— 动态路由实现了!