ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Cloud Gateway路由定义存储器源码解析与动态路由实战

Spring Cloud Gateway路由定义存储器源码解析与动态路由实战

自己在网关里加了一个自定义的RouteDefinitionRepository,重启之后却发现自己通过代码写入的路由根本不被加载,这是我实际项目中踩得最深的一类坑。最初我以为只是 Bean 没注入进来,翻来覆去检查配置,最后回到 Gateway 的自动装配源码里才看明白:问题不在我的 Repository 实现上,而在 Spring Cloud Gateway 对“路由定义存储器”这一层接口的组织方式上。RouteDefinitionRepository 在整个网关路由体系中承担的职责,比它表面多出来的那两个写方法要大得多。凡是涉及动态路由、多数据源路由管理、配置中心联动,扩展点最后都会落到这一层。这篇文章我会沿着数据模型、接口分工、默认实现到基于 Redis 的动态路由落地这条线,把 RouteDefinitionRepository 这条链路完整拆开讲一遍。

1. RouteDefinition 数据模型:存储器里到底长什么样

1.1 六个字段,一个都不能多想

先看存储对象本身。RouteDefinition不是一个复杂的类,但它决定了整个动态路由的扩展能力。核心代码基本就是六个字段:

public class RouteDefinition { private String id; private URI uri; private List<PredicateDefinition> predicates = new ArrayList<>(); private List<FilterDefinition> filters = new ArrayList<>(); private Map<String, Object> metadata = new HashMap<>(); private int order = 0; }

id是路由的唯一标识。在内存实现里,它直接作为 ConcurrentHashMap 的 key,所以 id 碰撞就意味着旧路由被静默覆盖,这是很多“路由突然不对了”的现场。uri是转发目标,可以写http://目标服务直接转发,也可以写lb://order-service这种形式,让网关通过负载均衡客户端去服务注册中心解析实际地址。predicates和filters是路由规则的核心:前者决定“什么请求会命中这条路由”,后者决定“命中之后怎么加工这个请求”。metadata是个自由扩展区,灰度版本号、超时时间、自定义属性都可以塞进去。最后order控制路由之间的优先级,数值越小越先匹配。

很多人容易忽略的一点是:predicates列表里多个条件之间是“并且”的关系,装配阶段会把它们合并成一个组合 Predicate;而filters列表是有顺序的,写在前面的过滤器会先执行。这两个字段虽然都是 List,语义上完全不同。

1.2 PredicateDefinition 与 FilterDefinition 的 name + args 设计

再往深一层,PredicateDefinition和FilterDefinition的结构几乎一模一样:

public class PredicateDefinition { private String name; private Map<String, String> args = new LinkedHashMap<>(); }

这个 name + args 的设计非常关键。它意味着网关核心并不需要知道每个谓词或过滤器的内部实现,只需要拿到一个“工厂名”和一组参数,然后在运行时交给对应的 Factory 去创建真正的对象。你在 yaml 里写的- Path=/demo/**,会被解析成name = Path、args = {"_genkey_0": "/demo/**"}。为什么会有_genkey_0这种奇怪 key?因为短格式配置没有显式参数名,工厂内部按位置读取参数,_genkey_0只是默认占位键。完整写法则是显式指定 name 和 args:

- name: Path args: pattern: /demo/**

两种写法最终得到的是同一个 PredicateDefinition。这种设计最大的好处是扩展性:以后新增一种谓词,不需要改动网关本体,只要写一个 GatewayPredicateFactory,然后注册成 Bean,配置文件里立刻就能用新名字。反过来,如果 Spring Cloud Gateway 把 Predicate 直接定义成强类型字段,那每次加能力都要改核心类,扩展成本会高很多。

1.3 RouteDefinition 与 Route:声明态和运行态的分界线

RouteDefinition 只是“声明态”的路由,它是纯数据。真正在匹配请求时使用的是Route,它的结构里包含的是可执行的 Predicate 实例和一组 GatewayFilter:

public class Route implements Ordered { private final String id; private final URI uri; private final int order; private final Predicate predicate; private final List<GatewayFilter> gatewayFilters; private final Map<String, Object> metadata; }

一次请求到达网关后,会按 order 顺序遍历 Route,依次用predicate.test(exchange)判断是否匹配,命中后按顺序执行 gatewayFilters。而 RouteDefinitionRouteLocator 就是连接声明态与运行态的那条流水线:它读入 RouteDefinition,把 PredicateDefinition 通过工厂转成 Predicate,把 FilterDefinition 转成 GatewayFilter,最后组装出 Route。理解了这条链路,你就明白动态路由的本质:改掉 RouteDefinition,再让流水线重新走一遍。

2. 三个接口的分工:Locator、Repository 与 RouteLocator

2.1 RouteDefinitionLocator:只管读,不问来源

RouteDefinitionLocator 是整个路由定义体系的根接口,全身上下只有一个方法:

public interface RouteDefinitionLocator { Flux<RouteDefinition> getRouteDefinitions(); }

它表达的含义很朴素:我不管你这些路由定义是从配置文件、内存、Redis 还是注册中心拿出来的,你只要给我返回一个 RouteDefinition 的响应式流就行。为什么返回Flux<RouteDefinition>而不是 List?因为数据源可能是异步的、流式的,比如从消息队列或注册中心持续推送数据,用 Flux 可以天然支持异步和背压。这个接口是数据源和路由装配器之间的依赖倒置点:装配器只依赖抽象,不关心具体实现。

2.2 RouteDefinitionRepository:在 Locator 之上补了写能力

RouteDefinitionRepository 直接继承了 RouteDefinitionLocator,并且追加了两个写方法:

public interface RouteDefinitionRepository extends RouteDefinitionLocator { Mono<Void> save(Mono<RouteDefinition> route); Mono<Void> delete(Mono<String> routeId); }

注意 save 和 delete 的参数也被设计成了 Mono,而不是普通对象。这是为了让调用方可以把自己异步拿到的数据直接传给 Repository,在数据到齐之后再执行写入。如果你有同步数据,套一层Mono.just(...)就好。这个接口的出现,意味着 Spring Cloud Gateway 把“可管理的路由源”和“只读的路由源”明确区分开了。GatewayAutoConfiguration 里有一段条件装配逻辑:如果没有用户自定义的 RouteDefinitionRepository,就默认创建一个InMemoryRouteDefinitionRepository。也就是说,内存仓库是兜底方案,只要你想把路由定义放进外部存储并支持动态增删,这个接口就是你的扩展点。

2.3 RouteLocator 与 RouteDefinitionRouteLocator:装配车间

RouteLocator 是另一个层面的接口,它直接返回运行态的 Route:

public interface RouteLocator { Flux<Route> getRoutes(); }

默认实现RouteDefinitionRouteLocator构造时接收四个东西:一个 RouteDefinitionLocator、一组 GatewayPredicateFactory、一组 GatewayFilterFactory、以及 GatewayProperties。核心逻辑就是拿 locator 里的定义,逐个调用工厂转换成可执行的 Route。这里有一个很常见的误解:有人觉得只要自己实现了 RouteDefinitionRepository,路由就能自动生效,其实中间还隔着一层装配。Repository 只是把“图纸”送进系统,真正把图纸变成“零件”的是 RouteDefinitionRouteLocator。三者的职责边界可以这样记:Locator 管数据来源,Repository 管数据增删,RouteLocator 管定义到运行态的转换。

接口核心方法语义典型实现
RouteDefinitionLocatorgetRouteDefinitions()只读路由定义Properties、自定义 Locator
RouteDefinitionRepositorysave / delete / getRouteDefinitions()可读写的路由定义源InMemory、基于 Redis 的实现
RouteLocatorgetRoutes()返回可执行 RouteRouteDefinitionRouteLocator

3. 源码拆解:内存实现、配置实现、组合器与缓存器

3.1 InMemoryRouteDefinitionRepository:ConcurrentHashMap 是怎么接响应式世界的

默认的内存实现代码非常短,但细节很多:

public class InMemoryRouteDefinitionRepository implements RouteDefinitionRepository { private final Map<String, RouteDefinition> routes = new ConcurrentHashMap<>(); @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.flatMap(r -> { routes.put(r.getId(), r); return Mono.empty(); }); } @Override public Mono<Void> delete(Mono<String> routeId) { return routeId.flatMap(id -> { routes.remove(id); return Mono.empty(); }); } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(routes.values()); } }

第一眼会好奇:为什么用 ConcurrentHashMap 而不是 HashMap?因为网关运行在响应式并发模型下,路由的增删查可能被多个事件循环线程同时触发,HashMap 在并发写时轻则丢数据,重则导致迭代死循环,所以并发容器是最低要求。第二,整个实现没有一个null判断,save 如果收到一个空的 Mono,flatMap 就不会执行,不会写入;但如果你传入的 RouteDefinition 本身 id 为 null,put 进去之后后面装配路由时大概率会出奇怪的问题。第三,Flux.fromIterable(routes.values())利用的是 ConcurrentHashMap 弱一致性的迭代器,它不会抛 ConcurrentModificationException,可以放心在读取过程中并发写入。这算是一个把同步集合适配到响应式流的典型范本。

3.2 PropertiesRouteDefinitionRepository:yaml 里的路由是怎么进系统的

配置文件里的路由走的是另一个专门的 Locator,这个类是 PropertiesRouteDefinitionRepository,它组合了 GatewayProperties:

public class PropertiesRouteDefinitionRepository implements RouteDefinitionLocator { private final GatewayProperties gatewayProperties; public PropertiesRouteDefinitionRepository(GatewayProperties gatewayProperties) { this.gatewayProperties = gatewayProperties; } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(gatewayProperties.getRoutes()); } }

也就是说,spring.cloud.gateway.routes 下那几段配置,启动时会被 Spring Boot 的配置绑定机制转成List<RouteDefinition>,存进 GatewayProperties,再由这个 Repository 暴露出去。绑定过程有一个容易忽略的细节:yaml 里写- Path=/demo/**这种字符串简写,转换为 PredicateDefinition 时走的是接受单个字符串的构造方法,它会自己拆 name 和 args。所以你在应用里看到的 PredicateDefinition 对象,其实是 Boot 在绑定阶段就已经帮你加工好的。

顺带一提,GatewayProperties 里除了 routes,还有 defaultFilters。这些默认过滤器在装配阶段会被拼接进每一条路由的过滤器列表尾部,所以配置文件路由和代码动态添加的路由,最终都会共享同一套默认过滤器规则。

3.3 CompositeRouteDefinitionLocator:多个数据源的并集语义

实际启动时,网关很少只注册一个 Locator。GatewayAutoConfiguration 会把配置类路由、内存仓库、服务发现路由等全部塞进一个 CompositeRouteDefinitionLocator:

public class CompositeRouteDefinitionLocator implements RouteDefinitionLocator { private final Flux<RouteDefinitionLocator> delegates; public CompositeRouteDefinitionLocator(Flux<RouteDefinitionLocator> delegates) { this.delegates = delegates; } @Override public Flux<RouteDefinition> getRouteDefinitions() { return delegates.flatMap(RouteDefinitionLocator::getRouteDefinitions); } }

flatMap在这里表达的是“多个数据源取并集”的语义。某个数据源返回空会直接跳过,不会中断其他源。默认的 concat 顺序基本就是 Bean 注册顺序,但强烈不建议依赖这个顺序来让后面的路由覆盖前面的结构。真正容易出问题的是异常行为:如果其中一个 Locator 在读取远程数据时抛了异常,整条 Flux 都会变成 error 流,最直接的结果就是路由刷新失败,甚至会拖累原有配置路由。所以自定义 RouteDefinitionRepository 时要格外关注异常边界,远程不可用时不能把整个路由装配带崩。

3.4 CachingRouteDefinitionLocator:一个默认不开的缓存器

再看一个容易被忽略的装饰器,CachingRouteDefinitionLocator:

public class CachingRouteDefinitionLocator implements RouteDefinitionLocator { private final RouteDefinitionLocator delegate; private final Map<String, RouteDefinition> cache = new LinkedHashMap<>(); public CachingRouteDefinitionLocator(RouteDefinitionLocator delegate) { this.delegate = delegate; cache.putAll(delegate.getRouteDefinitions() .collectMap(RouteDefinition::getId) .block()); } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(cache.values()); } }

构造时它会把 delegate 的路由定义一次性拉取到本地 Map,之后读路由直接走缓存。Spring Cloud Gateway 默认不启用这个类,原因很直接:一是在构造期调用 block() 会阻塞启动,如果 delegate 背后是慢速远程存储,启动时间会很尴尬;二是缓存一旦引入,RouteDefinitionRouteLocator 自身的刷新事件只能清掉它自己那一层缓存,这个 Caching 类的 Map 并不会被自动清理,很容易出现“改了数据源但路由就是不更新”的假象。所以我通常不建议在动态路由场景加这一层,如果你因为读源开销太大确实想加,必须自己监听 RefreshRoutesEvent,在事件里把缓存刷掉,否则动态路由的最后一公里永远走不通。

4. 动态路由实战:用 Redis 实现一个可用的 RouteDefinitionRepository

4.1 什么时候该自己动手写存储器

配置文件路由用起来简单,但一旦进入需要动态调整的场景就会卡住。比如管理后台想临时切走某个服务流量、网关在多个实例间需要共享同一份路由定义、或者运维希望在不重启进程的情况下调整路由参数,这时候纯 yaml 方案就不够用了。本地文件在一个实例上改了,其他实例的配置还是旧的,而且改完还得走发布流程。把路由定义搬到 Redis,再用自定义 RouteDefinitionRepository 暴露给网关,是目前实现动态路由最直接的一条路子。原因很朴素:Redis 读写快、API 简单、多个网关实例天然共享同一份数据,而且是 Java 生态里几乎人人都会用的组件。

4.2 最小可用实现:三个方法加一个 ID 集合

写一个最小可用版,需要实现三个方法,外加一个路由 ID 集合来避免用 keys 命令扫前缀:

@Component public class RedisRouteDefinitionRepository implements RouteDefinitionRepository { private static final String ROUTE_KEY_PREFIX = "gateway:route:"; private static final String ROUTE_IDS_KEY = "gateway:route:ids"; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; public RedisRouteDefinitionRepository(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, ApplicationEventPublisher eventPublisher) { this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; this.eventPublisher = eventPublisher; } @Override public Flux<RouteDefinition> getRouteDefinitions() { Set<String> routeIds = redisTemplate.opsForSet().members(ROUTE_IDS_KEY); if (routeIds == null || routeIds.isEmpty()) { return Flux.empty(); } return Flux.fromIterable(routeIds) .map(id -> redisTemplate.opsForValue().get(ROUTE_KEY_PREFIX + id)) .filter(Objects::nonNull) .map(this::deserialize); } @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.flatMap(r -> { if (!StringUtils.hasText(r.getId())) { return Mono.error(new IllegalArgumentException("路由 id 不能为空")); } redisTemplate.opsForValue().set(ROUTE_KEY_PREFIX + r.getId(), serialize(r)); redisTemplate.opsForSet().add(ROUTE_IDS_KEY, r.getId()); return Mono.empty(); }); } @Override public Mono<Void> delete(Mono<String> routeId) { return routeId.flatMap(id -> { redisTemplate.opsForValue().getAndDelete(ROUTE_KEY_PREFIX + id); redisTemplate.opsForSet().remove(ROUTE_IDS_KEY, id); return Mono.empty(); }); } private String serialize(RouteDefinition routeDefinition) { try { return objectMapper.writeValueAsString(routeDefinition); } catch (JsonProcessingException e) { throw new IllegalStateException("路由定义序列化失败", e); } } private RouteDefinition deserialize(String json) { try { return objectMapper.readValue(json, RouteDefinition.class); } catch (JsonProcessingException e) { throw new IllegalStateException("路由定义反序列化失败: " + json, e); } } }

几个实现细节值得注意。序列化用的 ObjectMapper,一定要复用 Spring Boot 自动配置的那个,而不是自己new ObjectMapper()。因为 Boot 托管的 ObjectMapper 已经装配好了 Java 8 时间模块等扩展,你自己 new 一个很可能会在序列化 metadata 中的时间字段时翻车。路由 ID 集合我非常推荐单独维护一个 Set,不要在生产环境用keys gateway:route:*去扫,数据量大时 Redis 会卡。最后,delete 方法在新版本 RedisTemplate 里提供了getAndDelete,顺手把旧值取出来,方便做变更日志。

4.3 发布 RefreshRoutesEvent:路由变更落地的最后一公里

Redis 存储写完之后,如果不通知网关,内存里那些运行态的 Route 还是老的。Spring Cloud Gateway 的事件机制在这里派上用场,标准做法是 publish 一个 RefreshRoutesEvent:

eventPublisher.publishEvent(new RefreshRoutesEvent(this));

RouteDefinitionRouteLocator 内部有一个 routeCache,它注册了一个事件监听器,收到 RefreshRoutesEvent 后会把缓存清空,下一次请求进来时重新从 RouteDefinitionLocator 拉取定义并转换。这就是动态路由变更落地的最后一公里。如果你用的是 Spring Boot Actuator,也可以直接POST /actuator/gateway/refresh触发同一个事件,但要注意这个端点需要暴露之后才可用。这里最容易犯的错是:save 只写 Redis,不发事件,结果接口明明返回了成功,网关却一整天都跑在旧路由上。

5. 路由不生效时的排查链路与多实例一致性

5.1 自定义 Locator 把默认组合器顶掉的坑

这是我开头提到的那类问题的根源。GatewayAutoConfiguration 中默认生成的 RouteDefinitionLocator,是一个包罗了配置路由、内存路由、服务发现路由的 CompositeRouteDefinitionLocator。但如果你在项目里自己声明了一个实现 RouteDefinitionLocator 接口的 Bean,由于自动配置上标了@ConditionalOnMissingBean(RouteDefinitionLocator.class),你的 Bean 会直接把默认组合器整个顶替掉。后果就是:配置文件里的路由全部消失,Spring Cloud Gateway 只认你那一个 Locator。而如果你实现的是 RouteDefinitionRepository,则只是往组合器里增加一个数据源,配置路由和内存路由都还在。所以扩展点的选择不是随意的,凡是能基于 RouteDefinitionRepository 解决的问题,就不要去实现 RouteDefinitionLocator。

5.2 路由 id 冲突与事件丢失

组合器把所有数据源的路由拼在一起之后,RouteDefinitionRouteLocator 在装配阶段是按 id 合并的,同 id 的路由定义,后加载的会覆盖先加载的。若想排查问题,先把各数据源的实际 id 打出来,看是不是哪个动态路由的 id 和 yaml 里的路由 id 重了。另一个常见现场是“定义了但没生效”,多半是事件没发出去。要确认事件链路是否正常,可以在 RefreshRoutesEvent 监听器里临时打日志,看每次刷新到底清没清缓存。

5.3 多实例网关的一致性问题

单个实例上动态路由能工作,不代表集群里就万事大吉。Redis 作为共享存储,确保了所有实例拿到的定义是一致的,但 RefreshRoutesEvent 只会在当前实例内部生效。A 实例上改了一条路由,A 自己能立刻刷新,B、C 实例如果没有任何通知,它们会继续沿用本地已经装配好的旧 Route。解决思路通常有两个:要么用 Redis 的发布订阅或消息队列,把“路由已变更”的通知广播到每个实例,每个实例收到后主动发布 RefreshRoutesEvent;要么借助配置中心的监听机制,感知配置变化后统一刷新。这也是动态路由真正落地的分水岭:单机能改不算完,全集群同步刷新才是目标。

5.4 顺着 actuator 端点反向定位

排查时我习惯先用 actuator 的两个端点把状态切开看。GET /actuator/gateway/routedefinitions返回的是声明态的路由定义,能看出定义到底有没有进到网关;GET /actuator/gateway/routes返回的是运行态 Route,能看出定义有没有成功转换并加载。如果 routedefinitions 里有数据,routes 里没有,说明问题出在装配阶段,比如谓词名称写错、URI 无法解析、某个 Filter 工厂不存在。如果 routedefinitions 里都没有,说明问题在 Locator/Repository 这一层,需要回头检查你自定义的 Repository 有没有被组合器聚合。利用这两个端点把问题范围缩小,大部分路由疑难杂症都能在五分钟内定位到具体环节。

最后给想自己动手扩展的人一个建议:不要一上来就写完整的分布式存储方案,先跑通“内存仓库 + 手动刷新事件”的最小闭环,再把存储层换成 Redis,最后才考虑多实例广播。我踩过的大部分坑都集中在两件事上:一个是扩展接口选错,把 Locator 和 Repository 搞混;另一个是刷新事件没发对,定义了新路由却不通知网关。这两点想清楚,后面的路就要顺得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进