☰
Spring Cloud Gateway源码解析:自定义RouteDefinitionRepository实现动态路由
2026/10/17 13:51:51 网站建设 项目流程

很多同学在接触 Spring Cloud Gateway(SCG)源码的时候,习惯先看路由匹配、过滤器链这些"请求链路"上的代码,但有个很不起眼却极其关键的抽象容易被一带而过,就是RouteDefinitionRepository。名字里有 Repository,说明它干的是数据存取的事,但具体存什么、怎么存、什么时候读,以及为什么默认实现是内存 Map 却还能支撑生产环境,这些细节才是真正理解 SCG 路由机制的分水岭。这篇内容我打算从源码层面把RouteDefinitionRepository讲透,顺带给出一个可落地的自定义实现方案,帮你把这个"存储器"彻底吃进肚子里。

RouteDefinitionRepository解决的问题很直接:SCG 需要一个统一的地方来获取"路由定义"(RouteDefinition),而这个获取动作发生在网关启动时、以及配置刷新时。搞清楚它的接口设计、默认行为、组合模式以及如何扩展,你就能明白为什么改了配置中心的网关路由不生效、为什么自己写的 Repository 总是不被调用、以及路由刷新事件的传播链路到底长什么样。这篇适合已经把网关跑起来、想深入理解内部机制的开发者,也适合正准备把路由定义从配置文件迁移到数据库或配置中心的同学。

1. RouteDefinitionRepository 在路由体系中的准确位置

1.1 先把"RouteDefinition"和"Route"这两个概念彻底分开

源码看多了你会发现,SCG 里有两个长得非常像的东西:一个叫RouteDefinition,一个叫Route。很多刚接触的人会把它们混为一谈,但这两者在整个链路里的职责完全不同。

RouteDefinition是"静态的、可持久化的路由描述"。你可以把它理解成一份表单数据:路由 ID、目标 URI、一组 Predicate 声明、一组 Filter 声明、还有可选的路由元数据(Metadata)。这份数据的特点是"可以被序列化、可以被存进任何存储介质、可以被用户直接编辑"。它甚至不关心Path=/api/**这种字符串真正匹配时应该怎么工作,因为它是纯声明式的数据。

Route则是"运行时实例"。SCG 真正用来做请求匹配和转发的是Route对象,它内部已经包含了编译好的Predicate(函数式接口)和Filter(GatewayFilter),这些实例已经和 Spring 容器、配置、Bean 工厂产生了关联。也就是说,Route是"活的",RouteDefinition是"死的"。

RouteDefinitionRepository操作的是前者。它负责把RouteDefinition从某个存储位置读出来、保存进去、或者删除掉。后续的RouteDefinitionRouteLocator会拿到这些定义,再结合GatewayProperties等配置,把它转换成真正的Route实例,最终放进路由缓存供请求匹配使用。这个先后顺序一旦理清,很多困惑就迎刃而解了。

1.2 Repository 在请求匹配链路中的"上游位置"

从一次 HTTP 请求进入网关说起,真正干活的是DispatcherHandler,它根据请求找到对应的HandlerMapping,网关场景下就是RoutePredicateHandlerMapping。这个 Mapping 会调用RouteLocator获取所有路由,并逐个用路由自带的 Predicate 去匹配当前请求。而RouteLocator有一个核心实现叫RouteDefinitionRouteLocator,这个类内部持有的正是RouteDefinitionRepository。

所以调用链是这样的:

DispatcherHandler -> RoutePredicateHandlerMapping.getHandler() -> RouteLocator.getRoutes() -> RouteDefinitionRouteLocator.getRoutes() -> RouteDefinitionRepository.getRouteDefinitions()

你注意,getRouteDefinitions()返回的是Flux<RouteDefinition>,意味着它天然支持响应式数据源。内存实现可以一次性把所有定义吐出来,但如果是数据库或者远程配置中心,完全可以是异步地分批吐出。这个接口设计的底层逻辑就是"不要规定存储介质,只规定数据形态和获取方式"。

RouteDefinitionRouteLocator拿到这些定义后,会通过RouteDefinitionLocator.getRouteDefinitions()的合并逻辑处理多个来源的配置,然后调用convertToRoute()完成从定义到运行时实例的转换。这一整套流程里,RouteDefinitionRepository就是数据的最上游,是路由体系的"水源"。

2. 接口设计与默认实现,源码逐行拆

2.1 接口为什么只定义三个方法

看一眼RouteDefinitionRepository的源码,极其简短:

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

只有三个方法:读、存、删。没有更新方法,没有批量方法,甚至没有分页。这个设计初看简陋,但背后有它的道理。

SCG 的路由定义来源本质上是"配置",配置的特点就是"全量读取、重载生效"。所以getRouteDefinitions()每次返回全量数据,调用方不需要关心增量变更。至于更新,那通常是先 delete 再 save,或者干脆整个配置源重刷一遍。这种设计虽然原始,但把接口约束降到了最低,任何存储介质都能比较容易地实现。

另外注意,save和delete的参数也都是Mono。这不是为了炫技,而是为了支持异步数据源。比如你的路由定义来自 R2DBC 数据库,那保存动作本身可能就是个异步操作,Mono包装可以完美承接这种响应式语义。

2.2 默认的内存实现:InMemoryRouteDefinitionRepository

不配置任何额外 Repository 的时候,SCG 使用的是InMemoryRouteDefinitionRepository。它的源码非常直观:

public class InMemoryRouteDefinitionRepository implements RouteDefinitionRepository { private final Map<String, RouteDefinition> routes = synchronizedMap(new LinkedHashMap<>()); @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()); } }

它用了一个LinkedHashMap加同步锁来保证线程安全,存取顺序是插入顺序。这个实现简单到让人怀疑它是不是真的能用在生产环境,但我可以明确地说:如果你的网关路由全部来自配置文件(如 application.yaml),并且不会在运行时动态增删路由,那么这个默认实现完全够用且几乎不会成为性能瓶颈。

原因在于,生产环境里更常见的做法是用 Spring Cloud Config 或 Nacos 等配置中心维护路由配置,然后通过GatewayProperties注入。此时真正提供服务的是PropertiesRouteDefinitionLocator,而非 Repository 的持久化能力——Repository 只是整个定位器体系的一环。内存 Map 的读写复杂度是 O(1),在高并发网关场景下,路由数量通常也就几十到几百条,性能差异根本感知不到。

2.3 组合模式:CompositeRouteDefinitionRepository 的合并逻辑

SCG 允许同时存在多个RouteDefinitionRepository的 Bean。如果你自己注入了一个,默认的InMemoryRouteDefinitionRepository依然会存在。此时所有 Repository 的getRouteDefinitions()结果会被CompositeRouteDefinitionRepository合并起来。

这个类的实现也很简单:

public class CompositeRouteDefinitionRepository implements RouteDefinitionRepository { private final List<RouteDefinitionRepository> repositories; public CompositeRouteDefinitionRepository(List<RouteDefinitionRepository> repositories) { this.repositories = repositories; } @Override public Flux<RouteDefinition> getRouteDefinitions() { return Flux.fromIterable(repositories) .flatMap(RouteDefinitionRepository::getRouteDefinitions); } }

它把所有 repository 的结果按顺序flatMap合并成一个Flux。这里有个隐含问题:如果多个 Repository 返回了相同的路由 ID,合并后的结果里会有两条同 ID 的路由定义。后面的转换逻辑不会自动去重,最终可能导致 Predicate 匹配时的行为不可预期。所以实践中一定要自己保证路由 ID 的唯一性,特别是当你同时使用多个数据源时。

在默认情况下,CompositeRouteDefinitionRepository里面只有一个成员,就是InMemoryRouteDefinitionRepository。只有当你显式声明了自定义 Repository 的 Bean 后,这个组合才会真正发挥"多数据源聚合"的作用。这一点意味着,你的自定义 Bean 不需要去取代默认实现,它会和默认实现共存——如果不想共存,得手动排除自动配置。

注意:组合模式的这个flatMap是无序的,因为响应式流天生不保证合并顺序。如果你的路由定义之间存在匹配先后依赖,比如想先匹配精确路径再匹配通配路径,千万别依赖 Repository 返回的顺序。正确的做法是通过谓词的优先级(如Path与Host的组合)或者路由定义在GatewayProperties中的 order 来控制。

3. 路由定义的加载、转换与刷新机制

3.1 RouteDefinitionRouteLocator 是怎么消费 Repository 的数据的

RouteDefinitionRouteLocator是路由定义到运行时路由的转换枢纽。它的核心方法是getRoutes(),返回值是Flux<Route>。我把精简后的逻辑梳理一下:

public Flux<Route> getRoutes() { return this.routeDefinitionLocator.getRouteDefinitions() .map(this::convertToRoute); }

routeDefinitionLocator在这里是RouteDefinitionLocator类型,而CompositeRouteDefinitionRepository正是它的一个实现(准确地说,RouteDefinitionRepository继承自RouteDefinitionLocator,而CompositeRouteDefinitionRepository实现了前者,所以天然也是后者的实现)。于是,整个过程中 Repository 的getRouteDefinitions()会被这里直接调用。

convertToRoute()做的事情,是把RouteDefinition里声明的谓词字符串解析成真正的Predicate实例,把过滤器定义解析成GatewayFilter实例列表,然后组装出Route。这个解析过程依赖RoutePredicateFactory和GatewayFilterFactory体系,本质上就是利用 Spring 的ShortcutConfigurable机制把配置字符串转成参数对象。

这里有个值得注意的细节:getRoutes()每次被调用都会重新走一遍"读取定义 -> 转换路由"的流程,并不会主动缓存结果。那为什么网关的匹配性能还是很高呢?因为RoutePredicateHandlerMapping内部维护了一个路由缓存RouteLocator的缓存,lookupRoute()会先从缓存拿,只有缓存被清掉或者刷新事件触发时才重新调用getRoutes()。这个缓存的 key 是请求路径,命中后直接返回对应的Route,不需要每次请求都重新解析路由定义。

3.2 RefreshRoutesEvent 事件驱动的路由刷新链路

动态改路由、改完不生效,是使用 SCG 时被问得最多的问题之一。要理解这个问题,必须知道RefreshRoutesEvent的存在。

当你调用ApplicationEventPublisher.publishEvent(new RefreshRoutesEvent(this))之后,RouteRefreshListener这个监听器会接管后续动作。它内部持有RouteDefinitionWriter(注意:RouteDefinitionRepository接口和RouteDefinitionWriter接口其实共享了save/delete方法签名,但它们是不同的接口类型),收到事件后它会清空路由缓存。

缓存清了之后,下一次请求进来,RoutePredicateHandlerMapping发现缓存里没有对应路由,就会重新调用RouteLocator.getRoutes(),也就是重新从RouteDefinitionRepository拉取全部定义并重新转换。这样就实现了"配置更新 -> 触发事件 -> 缓存失效 -> 重新加载"的闭环。

实际开发中,如果你把路由定义存在数据库里,想实现"数据库路由修改后网关自动生效",最简单的做法就是:在管理后台保存路由数据后,通过消息队列或直接远程调用网关的一个接口来发布RefreshRoutesEvent。这里有个经验:不要在 Repository 的save方法内部直接发布刷新事件,因为save方法的语义只是持久化,它不负责缓存的一致性。如果你在save里发事件,再配合RouteDefinitionRouteLocator的 getRoutes 调用,很容易造成循环加载,且难以排查问题。

3.3 配置文件里的路由和 Repository 里的路由是什么关系

很多人会疑惑:我在 application.yaml 里写了一大堆spring.cloud.gateway.routes,为什么不见得走了RouteDefinitionRepository?

因为配置文件的读取路径是另一条线。GatewayProperties会把 yaml 中的路由属性绑定成List<RouteDefinition>,然后由PropertiesRouteDefinitionLocator提供。这个 Locator 和RouteDefinitionRepository的实例们会被CompositeRouteDefinitionLocator聚合到一起统一为上层提供路由定义。默认情况下,CompositeRouteDefinitionLocator中同时包含PropertiesRouteDefinitionLocator以及RouteDefinitionRepository(经由组合仓库)两个数据源。

这意味着:如果你在配置文件和自定义 Repository 里定义了同 ID 的路由,最终这两个定义都会出现在getRouteDefinitions()的结果里。GC 不会因为 ID 相同就覆盖,它会傻傻地把两条都转换出来。实际匹配时,先匹配到哪条取决于路由的顺序,而这个顺序在 Composite 合并后并不稳定可控。这个坑我亲眼见过很多次,所以再次强调:多数据源时 ID 唯一性是绝对红线。

4. 自定义 RouteDefinitionRepository:把路由定义搬到数据库

4.1 一个能直接落地的 Jdbc 实现思路

如果你希望路由定义由业务后台动态管理,存进 MySQL 之类的数据库,那自定义一个 Repository 差不多是必经之路。我先给一个使用 Spring JDBC 的实现骨架,然后逐条说明关键决策。

@Component public class JdbcRouteDefinitionRepository implements RouteDefinitionRepository { private final JdbcTemplate jdbcTemplate; public JdbcRouteDefinitionRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public Flux<RouteDefinition> getRouteDefinitions() { List<RouteDefinition> definitions = jdbcTemplate.query( "SELECT id, uri, predicates, filters, metadata FROM route_definition", (rs, rowNum) -> { RouteDefinition definition = new RouteDefinition(); definition.setId(rs.getString("id")); definition.setUri(URI.create(rs.getString("uri"))); definition.setPredicates(parseList(rs.getString("predicates"))); definition.setFilters(parseList(rs.getString("filters"))); definition.setMetadata(parseMetadata(rs.getString("metadata"))); return definition; }); return Flux.fromIterable(definitions); } @Override public Mono<Void> save(Mono<RouteDefinition> route) { return route.flatMap(definition -> { jdbcTemplate.update( "INSERT INTO route_definition(id, uri, predicates, filters, metadata) VALUES (?, ?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE uri = VALUES(uri), predicates = VALUES(predicates), " + "filters = VALUES(filters), metadata = VALUES(metadata)", definition.getId(), definition.getUri().toString(), serializeList(definition.getPredicates()), serializeList(definition.getFilters()), serializeMetadata(definition.getMetadata())); return Mono.empty(); }); } @Override public Mono<Void> delete(Mono<String> routeId) { return routeId.flatMap(id -> { jdbcTemplate.update("DELETE FROM route_definition WHERE id = ?", id); return Mono.empty(); }); } }

几个关键点:

predicates和filters在RouteDefinition中其实是List<PredicateDefinition>和List<FilterDefinition>,不是简单的字符串列表。我在上面的示例里用的是parseList和serializeList这样的占位方法,生产环境更推荐用 JSON 列来存储,比如把整个List<PredicateDefinition>序列化成 JSON 字符串放进一个TEXT字段。这样parseList内部就是JSON.parseArray,存取都省心。

metadata是Map<String, Object>,也可以用 JSON 字符串存。唯一要注意的是,从数据库读出后反序列化得到的数值类型可能会变成Integer、Long或BigDecimal,而你在谓词或过滤器里可能期望的是String。这种类型不一致问题非常隐蔽,建议在parseMetadata里做一层类型适配。

4.2 为什么用了@Component就足够了,不需要额外配置

你只需要把这个类声明成 Spring Bean,SCG 的自动配置就会把它收进CompositeRouteDefinitionRepository。这里的原理是:RouteDefinitionRepository是一个单独的类型,Spring Boot 自动配置里会通过ObjectProvider注入所有该类型的 Bean,然后构造成组合仓库。

注意一个细节:加了@Component之后,内存仓库InMemoryRouteDefinitionRepository并不会消失,你的仓库会和它共存。如果两条数据源里都没有冲突路由 ID,那是没问题的。但如果你只想用自己的仓库完全替代默认行为,就需要排除自动配置。做法是在Application启动类上加exclude或者在配置文件中关闭相关自动配置类。

不过大部分场景里,共存并不会造成问题,因为GatewayProperties里如果没有配置路由,内存仓库本来也是空的。所以你先用@Component把自定义仓库跑起来,通常就够用了。

4.3 与 GatewayProperties 初始化顺序的坑

自定义 Repository 接入后,有一个非常常见的坑:JdbcTemplate的自动配置依赖DataSource,而DataSource的初始化又可能在网关路由定义加载之后才完成。如果路由定义加载逻辑在启动早期被触发,数据库还没就绪,就会报错或者拿到空路由。

但实际上,SCG 的路由加载是惰性的。首次请求进来时才会触发getRoutes()的完整调用链,此时DataSource早已初始化完毕。所以这种坑更多出现在你主动在ApplicationRunner或@PostConstruct里去调用路由加载的场景。我的建议是:不要在启动阶段主动触发路由加载,让网关保持"按需加载"。如果你确实需要在启动后立即把所有路由加载进缓存,可以考虑监听ApplicationReadyEvent,这时所有 Bean 和连接池都已就绪。

另一个经验是save和delete的调用时机。管理后台调用save时,参数里可能带着部分字段缺失的路由定义,比如没有uri。此时如果数据库约束严格,uri为 null 会导致写入失败。所以自定义实现里要加基础校验:id不能为空,uri必须合法,predicates至少要有一个。这些校验逻辑放在 Repository 里还是放在调用的 service 层,可以根据团队规范来,但我的建议是 Repository 只做存储,校验放 service 或 controller,保持单一职责。

5. 常见问题排查,以及我踩过的几个坑

5.1 路由不生效的几类典型原因速查

现象可能原因排查思路
修改数据库路由不生效未触发RefreshRoutesEvent,缓存没清确认是否发布刷新事件,看日志中是否有缓存清理记录
自定义 Repository 没有被调用Bean 未被 Spring 扫描到检查包路径扫描配置,确认类上有@Component或已注册为 Bean
路由定义了但不匹配多个数据源中路由 ID 冲突检查GatewayProperties和自定义数据源中的 ID 是否有重复
启动时数据源初始化失败启动阶段过早触发了路由加载移除@PostConstruct里的主动加载逻辑,等ApplicationReadyEvent
保存路由后重启丢失使用的是内存 Repository,未持久化确认自定义 Repository 是否真的生效,打印日志验证

这张表里的每一条我都遇到过不止一次,"自定义 Repository 没有被调用"其实是最高频的问题。排查手段也很简单:在getRouteDefinitions()方法里打一条日志,看网关启动或者刷新时有没有输出。如果完全没输出,说明你的 Bean 没进组合仓库,回头检查扫描路径比啥都强。

5.2 Predicate 解析失败不会立刻暴露,刷新时才炸

自定义 Repository 里的路由定义,如果predicates字符串写错了,比如用了不存在的谓词名,RouteDefinitionRouteLocator在转换时并不会直接抛错。它会得到空的谓词列表,最终生成一个"永远匹配不上"的路由。这种路由反映在表现上就是"接口 404",但日志里几乎没有异常,排查起来相当恶心。

我建议的排查方式是想办法打印转换后的Route信息,或者直接用一个测试用例把getRouteDefinitions()的返回值和convertToRoute()的行为跑一遍。如果你用的是配置中心,那还可以借助配置中心的历史版本对比,快速定位是哪次修改引入了错误数据。总之,别指望 SCG 会帮你校验路由定义的正确性,它在这方面的设计就是"你给什么,我装什么"。

5.3 高并发下路由缓存被频繁清空的隐患

有些团队实现了"每次数据库路由变更就发布刷新事件",但发布得太频繁。RefreshRoutesEvent触发后,路由缓存被清空,接下来的一波请求全部要走"重新拉取定义 + 重新转换路由"的完整链路。在路由数量多、谓词复杂的场景下,这一波请求的延迟会明显上升。

我的建议是:刷新事件要"短时间窗口内合并"。比如管理后台保存路由后,不直接发事件,而是通过一个延迟队列,在几百毫秒内合并多次变更信号,只发一次刷新事件。这样可以显著降低缓存重建的频率。对于大部分网关场景,路由数量几千条以内,转换耗时可能就几毫秒,但高频刷新带来的毛刺依然值得重视。

5.4 启动时配置来自内存仓库,数据库仓库"慢半拍"的问题

最后说一个很有意思的细节。如果GatewayProperties里没配置路由,但内存仓库里有一条路由(比如通过RouteDefinitionWriter动态加过),而你的数据库仓库里也有路由,那么启动后首次请求时,网关会从两个仓库各拿一份路由然后合并。如果数据库仓库响应很快,两者几乎同时返回,看不出问题。但如果数据库仓库因为网络或慢查询等原因延迟了几十毫秒,那首次请求就会等这两个数据源都返回后才能完成路由匹配。

解决思路有两种:一是尽量保证自定义仓库的查询足够快,索引和 SQL 要优化;二是如果不需要动态路由,干脆就不要自定义 Repository,所有路由走配置文件,省掉这一层不确定性。

6. 源码阅读路线建议,以及我个人的一点体会

如果你想把这条链路彻底看通,我建议按照这个顺序读源码:先读RouteDefinitionRepository接口,再看InMemoryRouteDefinitionRepository和CompositeRouteDefinitionRepository,接着看RouteDefinitionRouteLocator的getRoutes()和convertToRoute(),最后回到RoutePredicateHandlerMapping看路由缓存是怎么配合的。读完这条线,你不仅能回答"路由存在哪",还能搞清楚"改完路由怎么生效"。

我个人在刚接触这段源码时,也犯过一个低级错误:我在自定义 Repository 中把getRouteDefinitions()写成了阻塞式的Thread.sleep来模拟慢查询,结果网关请求直接卡住,这才真正理解了响应式流"禁止阻塞"的含义。SCG 整个链路是构建在 Reactor 之上的,任何阻塞调用都可能拖垮 Netty 的 event loop 线程。所以自定义 Repository 时,数据库访问一定得用异步驱动(如 R2DBC),或者至少在并发策略上做好隔离,千万别在getRouteDefinitions()里做同步 JDBC 调用并直接返回。这个代价,比想象中大得多。

最后再分享一个小技巧:如果你只是想调试路由定义加载过程,又不想加一堆日志代码,可以直接用 Spring Boot Actuator 的gateway端点,它能暴露当前所有路由信息。把它和自定义 Repository 的输出对照一下,很快就能定位数据源层面或者转换层面的问题。这个办法帮我省了好几次查日志的功夫,希望你也能用上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询