资讯详情

资讯详情

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 ListPredicateDefinition predicates new ArrayList(); private ListFilterDefinition filters new ArrayList(); private MapString, 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 MapString, 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 实例和一组 GatewayFilterpublic class Route implements Ordered { private final String id; private final URI uri; private final int order; private final Predicate predicate; private final ListGatewayFilter gatewayFilters; private final MapString, Object metadata; }一次请求到达网关后会按 order 顺序遍历 Route依次用predicate.test(exchange)判断是否匹配命中后按顺序执行 gatewayFilters。而 RouteDefinitionRouteLocator 就是连接声明态与运行态的那条流水线它读入 RouteDefinition把 PredicateDefinition 通过工厂转成 Predicate把 FilterDefinition 转成 GatewayFilter最后组装出 Route。理解了这条链路你就明白动态路由的本质改掉 RouteDefinition再让流水线重新走一遍。2. 三个接口的分工Locator、Repository 与 RouteLocator2.1 RouteDefinitionLocator只管读不问来源RouteDefinitionLocator 是整个路由定义体系的根接口全身上下只有一个方法public interface RouteDefinitionLocator { FluxRouteDefinition getRouteDefinitions(); }它表达的含义很朴素我不管你这些路由定义是从配置文件、内存、Redis 还是注册中心拿出来的你只要给我返回一个 RouteDefinition 的响应式流就行。为什么返回FluxRouteDefinition而不是 List因为数据源可能是异步的、流式的比如从消息队列或注册中心持续推送数据用 Flux 可以天然支持异步和背压。这个接口是数据源和路由装配器之间的依赖倒置点装配器只依赖抽象不关心具体实现。2.2 RouteDefinitionRepository在 Locator 之上补了写能力RouteDefinitionRepository 直接继承了 RouteDefinitionLocator并且追加了两个写方法public interface RouteDefinitionRepository extends RouteDefinitionLocator { MonoVoid save(MonoRouteDefinition route); MonoVoid delete(MonoString routeId); }注意 save 和 delete 的参数也被设计成了 Mono而不是普通对象。这是为了让调用方可以把自己异步拿到的数据直接传给 Repository在数据到齐之后再执行写入。如果你有同步数据套一层Mono.just(...)就好。这个接口的出现意味着 Spring Cloud Gateway 把“可管理的路由源”和“只读的路由源”明确区分开了。GatewayAutoConfiguration 里有一段条件装配逻辑如果没有用户自定义的 RouteDefinitionRepository就默认创建一个InMemoryRouteDefinitionRepository。也就是说内存仓库是兜底方案只要你想把路由定义放进外部存储并支持动态增删这个接口就是你的扩展点。2.3 RouteLocator 与 RouteDefinitionRouteLocator装配车间RouteLocator 是另一个层面的接口它直接返回运行态的 Routepublic interface RouteLocator { FluxRoute getRoutes(); }默认实现RouteDefinitionRouteLocator构造时接收四个东西一个 RouteDefinitionLocator、一组 GatewayPredicateFactory、一组 GatewayFilterFactory、以及 GatewayProperties。核心逻辑就是拿 locator 里的定义逐个调用工厂转换成可执行的 Route。这里有一个很常见的误解有人觉得只要自己实现了 RouteDefinitionRepository路由就能自动生效其实中间还隔着一层装配。Repository 只是把“图纸”送进系统真正把图纸变成“零件”的是 RouteDefinitionRouteLocator。三者的职责边界可以这样记Locator 管数据来源Repository 管数据增删RouteLocator 管定义到运行态的转换。接口核心方法语义典型实现RouteDefinitionLocatorgetRouteDefinitions()只读路由定义Properties、自定义 LocatorRouteDefinitionRepositorysave / delete / getRouteDefinitions()可读写的路由定义源InMemory、基于 Redis 的实现RouteLocatorgetRoutes()返回可执行 RouteRouteDefinitionRouteLocator3. 源码拆解内存实现、配置实现、组合器与缓存器3.1 InMemoryRouteDefinitionRepositoryConcurrentHashMap 是怎么接响应式世界的默认的内存实现代码非常短但细节很多public class InMemoryRouteDefinitionRepository implements RouteDefinitionRepository { private final MapString, RouteDefinition routes new ConcurrentHashMap(); Override public MonoVoid save(MonoRouteDefinition route) { return route.flatMap(r - { routes.put(r.getId(), r); return Mono.empty(); }); } Override public MonoVoid delete(MonoString routeId) { return routeId.flatMap(id - { routes.remove(id); return Mono.empty(); }); } Override public FluxRouteDefinition getRouteDefinitions() { return Flux.fromIterable(routes.values()); } }第一眼会好奇为什么用 ConcurrentHashMap 而不是 HashMap因为网关运行在响应式并发模型下路由的增删查可能被多个事件循环线程同时触发HashMap 在并发写时轻则丢数据重则导致迭代死循环所以并发容器是最低要求。第二整个实现没有一个null判断save 如果收到一个空的 MonoflatMap 就不会执行不会写入但如果你传入的 RouteDefinition 本身 id 为 nullput 进去之后后面装配路由时大概率会出奇怪的问题。第三Flux.fromIterable(routes.values())利用的是 ConcurrentHashMap 弱一致性的迭代器它不会抛 ConcurrentModificationException可以放心在读取过程中并发写入。这算是一个把同步集合适配到响应式流的典型范本。3.2 PropertiesRouteDefinitionRepositoryyaml 里的路由是怎么进系统的配置文件里的路由走的是另一个专门的 Locator这个类是 PropertiesRouteDefinitionRepository它组合了 GatewayPropertiespublic class PropertiesRouteDefinitionRepository implements RouteDefinitionLocator { private final GatewayProperties gatewayProperties; public PropertiesRouteDefinitionRepository(GatewayProperties gatewayProperties) { this.gatewayProperties gatewayProperties; } Override public FluxRouteDefinition getRouteDefinitions() { return Flux.fromIterable(gatewayProperties.getRoutes()); } }也就是说spring.cloud.gateway.routes 下那几段配置启动时会被 Spring Boot 的配置绑定机制转成ListRouteDefinition存进 GatewayProperties再由这个 Repository 暴露出去。绑定过程有一个容易忽略的细节yaml 里写- Path/demo/**这种字符串简写转换为 PredicateDefinition 时走的是接受单个字符串的构造方法它会自己拆 name 和 args。所以你在应用里看到的 PredicateDefinition 对象其实是 Boot 在绑定阶段就已经帮你加工好的。顺带一提GatewayProperties 里除了 routes还有 defaultFilters。这些默认过滤器在装配阶段会被拼接进每一条路由的过滤器列表尾部所以配置文件路由和代码动态添加的路由最终都会共享同一套默认过滤器规则。3.3 CompositeRouteDefinitionLocator多个数据源的并集语义实际启动时网关很少只注册一个 Locator。GatewayAutoConfiguration 会把配置类路由、内存仓库、服务发现路由等全部塞进一个 CompositeRouteDefinitionLocatorpublic class CompositeRouteDefinitionLocator implements RouteDefinitionLocator { private final FluxRouteDefinitionLocator delegates; public CompositeRouteDefinitionLocator(FluxRouteDefinitionLocator delegates) { this.delegates delegates; } Override public FluxRouteDefinition getRouteDefinitions() { return delegates.flatMap(RouteDefinitionLocator::getRouteDefinitions); } }flatMap在这里表达的是“多个数据源取并集”的语义。某个数据源返回空会直接跳过不会中断其他源。默认的 concat 顺序基本就是 Bean 注册顺序但强烈不建议依赖这个顺序来让后面的路由覆盖前面的结构。真正容易出问题的是异常行为如果其中一个 Locator 在读取远程数据时抛了异常整条 Flux 都会变成 error 流最直接的结果就是路由刷新失败甚至会拖累原有配置路由。所以自定义 RouteDefinitionRepository 时要格外关注异常边界远程不可用时不能把整个路由装配带崩。3.4 CachingRouteDefinitionLocator一个默认不开的缓存器再看一个容易被忽略的装饰器CachingRouteDefinitionLocatorpublic class CachingRouteDefinitionLocator implements RouteDefinitionLocator { private final RouteDefinitionLocator delegate; private final MapString, RouteDefinition cache new LinkedHashMap(); public CachingRouteDefinitionLocator(RouteDefinitionLocator delegate) { this.delegate delegate; cache.putAll(delegate.getRouteDefinitions() .collectMap(RouteDefinition::getId) .block()); } Override public FluxRouteDefinition getRouteDefinitions() { return Flux.fromIterable(cache.values()); } }构造时它会把 delegate 的路由定义一次性拉取到本地 Map之后读路由直接走缓存。Spring Cloud Gateway 默认不启用这个类原因很直接一是在构造期调用 block() 会阻塞启动如果 delegate 背后是慢速远程存储启动时间会很尴尬二是缓存一旦引入RouteDefinitionRouteLocator 自身的刷新事件只能清掉它自己那一层缓存这个 Caching 类的 Map 并不会被自动清理很容易出现“改了数据源但路由就是不更新”的假象。所以我通常不建议在动态路由场景加这一层如果你因为读源开销太大确实想加必须自己监听 RefreshRoutesEvent在事件里把缓存刷掉否则动态路由的最后一公里永远走不通。4. 动态路由实战用 Redis 实现一个可用的 RouteDefinitionRepository4.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 FluxRouteDefinition getRouteDefinitions() { SetString 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 MonoVoid save(MonoRouteDefinition 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 MonoVoid delete(MonoString 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 一个 RefreshRoutesEventeventPublisher.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 搞混另一个是刷新事件没发对定义了新路由却不通知网关。这两点想清楚后面的路就要顺得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →