Gateway读取nacos配置中心中的路由规则

By ref-nobody 创建时间 2025年5月11日 | 本文最后更新于 2025年5月25日 #nacos, #动态配置

本文描述了如何使用nacos作为配置中心来动态配置gateway的路由规则,并实现实时更新。

github代码地址:spring-boot-gateway-nacos-registry 欢迎共同协作。

使用的版本:spring-cloud-starter-alibaba-nacos-config:2021.0.1.0,spring-cloud-starter-gateway:3.1.4

平时练习的时候,一般在项目的yaml文件中配置gateway的路由规则,可以进行快速的学习和验证。一个示例配置如下:

spring:
  cloud:
    gateway:
      routes:
        - id: route1
          uri: lb://http-request
          predicates:
            - Before=2025-05-09T22:08:00.639764+08:00[Asia/Shanghai]
            - Path=/rt/**
          filters:
            - StripPrefix=1
        - id: route2
          uri: lb://http-request
          predicates:
            - Path=/rt/**
            - After=2025-05-09T22:08:00.639764+08:00[Asia/Shanghai]
          filters:
            - RewritePath=/rt/?(?<segment>.*), /new/$\{segment}

上面定义了两个路由规则route1和route2,规则的配置不是此次的重点。为了实现路由信息的动态配置,接下来考虑如何接入配置中心nacos,将上述的内容使用json格式保存到nacos中。

分析

还是从yaml配置出发,使用spring.cloud.gateway.routes的配置会被解析到配置类GatewayProperties的routes属性中。我们查看属性的getRoutes方法在哪里使用到了,使用idea的自动跳转,我们会转到org.springframework.cloud.gateway.config.PropertiesRouteDefinitionLocator#getRouteDefinitions这个方法上面。

我们聚焦到这个类的接口RouteDefinitionLocator上面,这个接口定义了获取路由定义的抽象,我们查看其实现类:

可以看到见名知义的几个实现,将路由定义分别保存到了内存redisproperties配置类中。我们基于nacos的配置实现也是基于此接口进行拓展。
这里说一下RouteDefinitionRepository这个接口,这个接口实现了RouteDefinitionLocator接口,另外还实现了RouteDefinitionWriter接口提供路由信息的增删能力,InMemory和Redis都是实现的此接口。我们使用nacos配置中心只提供路由信息的获取即可。

NacosRouteDefinitionRepository实现

引入配置中心依赖:

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

创建实现类实现RouteDefinitionLocator接口:

@Component
@AutoConfigureAfter(NacosConfigProperties.class)
public class NacosRouteDefinitionRepository implements RouteDefinitionLocator {
    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
    }
}

实现步骤如下:

  1. 读取配置文件中nacos的连接;
  2. 创建ConfigService连接到nacos;
  3. 程序启动时读取文件信息,初始化路由信息;
  4. 添加配置文件的监听,可以动态实时刷新路由信息;

配置nacos连接信息

使用spring cloud nacos config的配置,添加和nacos控制台对应的信息:

我这里自己定义了一个命名空间,因此在配置的时候也要进行指定。

spring:
cloud:
nacos:
# 添加nacos配置中心的配置
config:
server-addr: localhost:8848
username: nacos
password: nacos
namespace: 9bf28ee1-daf9-4932-a453-a18de35a8be8

gateway-route-config.json文件配置
[
  {
    "id": "route1",
    "uri": "lb://http-request",
    "predicates": [
      {
        "name": "Before",
        "args": {
          "datetime": "2025-05-10T14:21:20.639764+08:00[Asia/Shanghai]"
        }
      },
      {
        "name": "Path",
        "args": {
          "pattern": "/rt/**"
        }
      }
    ],
    "filters": [
      {
        "name": "StripPrefix",
        "args": {
          "parts": 1
        }
      }
    ]
  },
  {
    "id": "route2",
    "uri": "lb://http-request",
    "predicates": [
      {
        "name": "Path",
        "args": {
          "pattern": "/rt/**"
        }
      },
      {
        "name": "After",
        "args": {
          "datetime": "2025-05-10T14:21:20.639764+08:00[Asia/Shanghai]"
        }
      }
    ],
    "filters": [
      {
        "name": "RewritePath",
        "args": {
          "regexp": "/rt/?(?<segment>.*)",
          "replacement": "/new/${segment}"
        }
      }
    ]
  }
]

创建ConfigService连接到nacos

作为客户端需要使用ConfigService创建nacos的连接,我们使用NacosFactory.createConfigService来创建

this.configService = NacosFactory.createConfigService(this.properties);

nacos的连接信息构造如下,里面包含了连接地址,命名空间,用户名/密码:

    private Properties buildProperties(String serverAddr) {
        Properties properties = new Properties();
        properties.setProperty(PropertyKeyConst.SERVER_ADDR, serverAddr);
        properties.setProperty(PropertyKeyConst.NAMESPACE, namespace);
        properties.setProperty(PropertyKeyConst.USERNAME, "nacos");
        properties.setProperty(PropertyKeyConst.PASSWORD, "nacos");
        return properties;
    }

在getRouteDefinitions方法中读取nacos配置文件

在程序启动的时候gateway会调用该方法加载路由定义信息。我们通过configService来从nacos中获取数据:

    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        // 从nacos中获取配置信息
        try {
            String config = configService.getConfig(dataId, groupId, 5000);
            // 获取到的是json数据,需要转换为RouteDefinition对象
            ObjectMapper mapper = new ObjectMapper();
            try {
                List<RouteDefinition> routes = mapper.readerForListOf(RouteDefinition.class).readValue(config);
                return Flux.fromIterable(routes);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        } catch (NacosException e) {
            throw new RuntimeException(e);
        }
    }

这里的重点是两部分:1. 使用ConfigService.getConfig读取配置信息;2. 由于读取到的是字符串类型,需要我们反序列化成RouteDefinition对象。
执行到这里,我们的程序启动之后就能读取到配置文件的信息了,但是还没有办法完成动态更新,接下来需要添加监听器。

添加nacos监听器监听配置

nacos的监听器是com.alibaba.nacos.api.config.listener.Listener接口,里面有两个方法:

public interface Listener {
    
    /**
     * Get executor for execute this receive.
     *
     * @return Executor
     */
    Executor getExecutor();
    
    /**
     * Receive config info.
     *
     * @param configInfo config info
     */
    void receiveConfigInfo(final String configInfo);
}

getExecutor方法返回一个线程池,当接受到新的信息的时候,使用线程池中的线程进行数据的更新。receiveConfigInfo方法则是负责接受新的配置数据,当我们在nacos中修改配置之后,configInfo参数会收到最新的数据。

我们使用匿名类创建一个Listener接口的实例:

    private final ExecutorService pool = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<Runnable>(1), new NameThreadFactory("update-gt-config"),
            new ThreadPoolExecutor.DiscardOldestPolicy());
...
        // 创建nacos监听器
        this.configListener = new Listener() {
            @Override
            public Executor getExecutor() {
                return pool;
            }
            @Override
            public void receiveConfigInfo(String configInfo) {
                // 解析规则并将规则信息加载到gateway中
                publisher.publishEvent(new RefreshRoutesEvent(this)); // 触发重新加载路由
            }
        };

receiveConfigInfo方法中接受到最新的数据之后,我们会发布一个RefreshRoutesEvent事件,gateway内部会监听到这个事件,然后重新调用我们之前实现的getRouteDefinitions方法拉取路由配置信息。

验证

手动修改nacos中的配置信息,观察控制台打印:

在这里我们可以开启日志的debug观察日志信息:

logging:
  level:
    org:
      springframework:
        cloud:
          gateway: DEBUG

在上面的路由规则配置中,我们http-request服务定义了两个请求/from-gl和/new/from-gl请求,通过before和after的断言,执行这样的逻辑:
存在两个服务分别是gateway服务和http-request服务,时间点在2025-05-09T22:08:00.639764+08:00[Asia/Shanghai]之前请求gateway的/rt/from-gl会被转发到http-request的/from-gl;时间点之后的请求则会被转发到http-request的/new/from-gl;通过nacos动态配置路由,然后进行验证:

Gateway的事件通知

接下来详细聊聊gateway的事件通知实现。gateway使用自动配置类GatewayAutoConfiguration进行自动配置,其中包括事件监听类RouteRefreshListener,该类实现了Spring的事件监听接口ApplicationListener

gateway会监听容器的刷新事件ContextRefreshedEvent,接收到事件之后会通过再次发布事件的方式触发gateway自己的路由规则刷新:

public void onApplicationEvent(ApplicationEvent event) {
	if (event instanceof ContextRefreshedEvent) {
		ContextRefreshedEvent refreshedEvent = (ContextRefreshedEvent) event;
		if (!WebServerApplicationContext.hasServerNamespace(refreshedEvent.getApplicationContext(), "management")) {
			reset();
		}
	}
}

private void reset() {
	this.publisher.publishEvent(new RefreshRoutesEvent(this));
}

RefreshRoutesEvent的事件处理逻辑如下:

从上面的图中可以看出,在监听器CachingRouteLocator监听到RefreshRoutesEvent事件后,会通过代理一直调用到RouteDefinitionLocator接口的实现类,调用getRouteDefinitions方法获取路由信息。

问题记录

与服务注册发现同时使用,造成多次加载路由信息的问题

由于我们使用网关的时候通常会结合服务注册与发现负载均衡来使用,这样我们在配置路由的uri的时候可以使用lb://...的格式,负载均衡的来调用其他服务。于是添加了discovery依赖:

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

当我们在验证的时候,开启了debug级别的日志打印,会发现控制台打印每隔一个间隔会重复加载并打印路由信息。进行debug排查发现定时打印是由名为“Nacos-Watch-Task-Schedule-”的线程打印的,全局搜索后定位到NacosWatch类。

NacosWatch实现了SmartLifecycle接口,也参与到了spring容器的生命周期中。在start方法中开启了一个固定的周期任务

/**
 * watch delay,duration to pull new service from nacos server.
 */
private long watchDelay = 30000;

this.watchFuture = this.taskScheduler.scheduleWithFixedDelay(
					this::nacosServicesWatch, this.properties.getWatchDelay());

执行的固定周期默认是30毫秒,执行的任务则是发布心跳事件HeartbeatEvent

	public void nacosServicesWatch() {

		// nacos doesn't support watch now , publish an event every 30 seconds.
		this.publisher.publishEvent(
				new HeartbeatEvent(this, nacosWatchIndex.getAndIncrement()));

	}

gateway在捕获到事件之后会执行resetIfNeeded方法,在方法中会去判断从事件接收到的值和上一次保存的值是否相同,如果不同则重新读取配置:

	private void resetIfNeeded(Object value) {
		if (this.monitor.update(value)) {
			reset();
		}
	}

从nacosServicesWatch任务中可以看到,事件携带的值是 nacosWatchIndex.getAndIncrement()生成的,每次会从之前的基础上+1,因此this.monitor.update(value)=true,每次接收到事件都会执行reset方法。

查看NacosWatch的自动配置类可以看到,启用条件是spring.cloud.nacos.discovery.watch.enabled配置为true,因此这个是可以关掉的,但该类负责 监听服务实例列表的变化 并实时更新本地服务缓存。如果关闭 NacosWatch会影响服务的动态更新能力。因此此处问题暂时保留……

	@Bean
	@ConditionalOnMissingBean
	@ConditionalOnProperty(value = "spring.cloud.nacos.discovery.watch.enabled",
			matchIfMissing = true)
	public NacosWatch nacosWatch(NacosServiceManager nacosServiceManager,
			NacosDiscoveryProperties nacosDiscoveryProperties) {
		return new NacosWatch(nacosServiceManager, nacosDiscoveryProperties);
	}

暂且搁置上面的现象,类似的问题还出现在NacosAutoServiceRegistration类中,这个类是在我们引入spring-cloud-starter-alibaba-nacos-discovery包之后进行服务自动注册的类,该类继承自AbstractAutoServiceRegistration类,同样是一个事件监听类,监听Web容器初始化事件。在监听事件处理中会进行服务的注册,并且发布实例注册事件InstanceRegisteredEvent

上面我们提到在gateway中,有一个事件监听类RouteRefreshListener,在前面的描述中我们提到它会监听到ContextRefreshedEvent事件从而完成路由规则的初始化。还是在同样的位置它还会监听到InstanceRegisteredEvent事件,执行路由信息读取,这样就会导致程序启动时会读取两次路由信息。

Leave a Reply

Your email address will not be published. Required fields are marked *

目录