一,统一配置管理

到目前为止我们已经解决了微服务相关的几个问题:

  • 微服务远程调用
  • 微服务注册、发现
  • 微服务请求路由、负载均衡
  • 微服务登录用户信息传递

不过,现在依然还有几个问题需要解决:

  • 网关路由在配置文件中写死了,如果变更必须重启微服务
  • 某些业务配置在配置文件中写死了,每次修改都要重启服务
  • 每个微服务都有很多重复的配置,维护成本高

这些问题都可以通过统一的配置管理器服务解决。而 Nacos 不仅仅具备注册中心功能,也具备配置管理的功能:

2953321-20250424203952695-670806160.png

1.1 配置共享

我们可以把微服务共享的配置抽取到 Nacos 中统一管理,这样就不需要每个微服务都重复配置了。分为两步:

  1. 在Nacos中添加共享配置
  2. 微服务拉取配置

1.1.1 在Nacos中添加共享配置

以 cart-service 为例,我们看看有哪些配置是重复的,可以抽取的:

首先是 jdbc 相关配置:

2953321-20250424204125080-120878420.png

然后是日志配置:

2953321-20250424204145284-726678620.png

然后是 swagger 以及 OpenFeign 的配置:

2953321-20250424204201709-1226546522.png

我们在 nacos 控制台分别添加这些配置。

  • 首先是jdbc相关配置,在配置管理->配置列表中点击+新建一个配置:

2953321-20250424204225310-298671089.png

在弹出的表单中填写信息:
2953321-20250424204256265-1097339274.png

注意这里的 jdbc 的相关参数并没有写死,例如:

  • 数据库ip:通过${hm.db.host:192.168.150.101}配置了默认值为192.168.150.101,同时允许通过${hm.db.host}来覆盖默认值
  • 数据库端口:通过${hm.db.port:3306}配置了默认值为3306,同时允许通过${hm.db.port}来覆盖默认值
  • 数据库database:可以通过${hm.db.database}来设定,无默认值

然后是统一的日志配置,命名为shared-log.``yaml,配置内容如下

    logging:
      level:
        com.hmall: debug
      pattern:
        dateformat: HH:mm:ss:SSS
      file:
        path: "logs/${spring.application.name}"

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

    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.1.2 微服务拉取配置

接下来,我们要在微服务拉取共享配置。将拉取到的共享配置与本地的application.yaml配置合并,完成项目上下文的初始化。

不过,需要注意的是,读取 Nacos 配置是 SpringCloud 上下文(ApplicationContext)初始化时处理的,发生在项目的引导阶段。然后才会初始化 SpringBoot 上下文,去读取application.yaml

也就是说引导阶段,application.yaml文件尚未读取,根本不知道 nacos 地址,该如何去加载 nacos 中的配置文件呢?

SpringCloud 在初始化上下文的时候会先读取一个名为bootstrap.yaml( 或者bootstrap.properties) 的文件,如果我们将 nacos 地址配置到bootstrap.yaml中,那么在项目引导阶段就可以读取 nacos 中的配置了。

2953321-20250424204705407-1811492192.png

因此,微服务整合 Nacos 配置管理的步骤如下:

  1. 引入依赖:在 cart-service 模块引入依赖:

      <!--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

    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 需要修改为:

    server:
      port: 8082
    feign:
      okhttp:
        enabled: true # 开启OKHttp连接池支持
    hm:
      swagger:
        title: 购物车服务接口文档
        package: com.hmall.cart.controller
      db:
        database: hm-cart
    

2.2 配置热更新

有很多的业务相关参数,将来可能会根据实际情况临时调整。例如购物车业务,购物车数量有一个上限,默认是 10,对应代码如下:

2953321-20250424205058421-1162964666.png

现在这里购物车是写死的固定值,我们应该将其配置在配置文件中,方便后期修改。但现在的问题是,即便写在配置文件中,修改了配置还是需要重新打包、重启服务才能生效。能不能不用重启,直接生效呢?

这就要用到 Nacos 的配置热更新能力了,分为两步:

  • 在Nacos中添加配置
  • 在微服务读取配置

2.2.1 添加配置到Nacos

首先,我们在 nacos 中添加一个配置文件,将购物车的上限数量添加到配置中:

2953321-20250424205143867-750201379.png

注意文件的 dataId 格式:

[服务名]-[spring.active.profile].[后缀名]

文件名称由三部分组成:

  • 服务名:我们是购物车服务,所以是cart-service
  • spring.active.profile:就是spring boot中的spring.active.profile,可以省略,则所有profile共享该配置
  • 后缀名:例如yaml

这里我们直接使用cart-service.yaml这个名称,则不管是 dev 还是 local 环境都可以共享该配置。

配置内容如下:

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

提交配置,在控制台能看到新添加的配置:

2953321-20250424205237392-2116990027.png

2.2.2 配置热更新

接着,我们在微服务中读取配置,实现配置热更新。在cart-service中新建一个属性读取类:

2953321-20250424205328686-418214732.png

代码如下:

package com.hm.cart.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@RefreshScope
@Data
@Component
@ConfigurationProperties(prefix = "hm.cart")
public class CartProperties {
    private Integer maxAmount;
}

接着,在业务中使用该属性加载类:

2953321-20250424205429752-211951829.png

3.3 动态路由

网关的路由配置全部是在项目启动时由

org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个 Map),不会改变。也不会监听路由变更,所以,我们无法利用配置热更新来实现路由更新。

因此,我们必须监听 Nacos 的配置变更,然后手动把最新的路由更新到路由表中。这里有两个难点:

  • 如何监听Nacos配置变更?
  • 如何把路由信息更新到路由表?

3.3.1 监听Nacos配置变更

在 Nacos 官网中给出了手动监听 Nacos 配置变更的 SDK:Java SDK

如果希望 Nacos 推送配置变更,可以使用 Nacos 动态监听配置接口来实现。

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

请求参数说明:

参数名参数类型描述
dataIdstring配置 ID,保证全局唯一性,只允许英文字符和 4 种特殊字符("."、":"、"-"、"_")。不超过 256 字节。
groupstring配置分组,一般是默认的DEFAULT_GROUP。
listenerListener监听器,配置变更进入监听器的回调函数。

示例代码

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;
        }
});

这里核心的步骤有 2 步:

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

由于我们采用了spring-cloud-starter-alibaba-nacos-config自动装配,因此ConfigService已经在com.alibaba.cloud.nacos.NacosConfigAutoConfiguration中自动创建好了:

2953321-20250424205807983-834948379.png

NacosConfigManager 中是负责管理 Nacos 的 ConfigService 的,具体代码如下:

2953321-20250424205810322-1740094008.png

因此,只要我们拿到NacosConfigManager就等于拿到了ConfigService,第一步就实现了。

第二步,编写监听器。虽然官方提供的 SDK 是 ConfigService 中的 addListener,不过项目第一次启动时不仅仅需要添加监听器,也需要读取配置,因此建议使用的 API 是这个:

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

既可以配置监听器,并且会根据 dataId 和 group 读取配置并返回。我们就可以在项目启动时先更新一次路由,后续随着配置变更通知到监听器,完成路由更新。

3.3.2 更新路由

更新路由要用到org.springframework.cloud.gateway.route.RouteDefinitionWriter这个接口:

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 来保存,格式如下:

{
  "id": "item",
  "predicates": [{
    "name": "Path",
    "args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
  }],
  "filters": [],
  "uri": "lb://item-service"
}

以上 JSON 配置就等同于:

spring:
  cloud:
    gateway:
      routes:
        - id: item
          uri: lb://item-service
          predicates:
            - Path=/items/**,/search/**

3.3.3 实现动态路由

  1. 我们在网关 gateway 引入依赖

    <!--统一配置管理-->
    <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. 在网关gatewayresources目录创建bootstrap.yaml文件,内容如下:

    spring:
      application:
        name: gateway
      cloud:
        nacos:
          server-addr: 192.168.150.101
          config:
            file-extension: yaml
            shared-configs:
              - dataId: shared-log.yaml # 共享日志配置
    
  3. 修改gatewayresources目录下的application.yml,把之前的路由移除,最终内容如下:

    server:
      port: 8080 # 端口
    hm:
      jwt:
        location: classpath:hmall.jks # 秘钥地址
        alias: hmall # 秘钥别名
        password: hmall123 # 秘钥文件密码
        tokenTTL: 30m # 登录有效期
      auth:
        excludePaths: # 无需登录校验的路径
          - /search/**
          - /users/login
          - /items/**
    
  4. gateway中定义配置监听器

    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());
            });
        }
    }
    
  5. 在 Nacos 控制台添加路由,路由文件名为gateway-routes.json,类型为json

    [
        {
            "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"
        }
    ]