news 2026/10/16 1:18:30

Spring Cloud Gateway WebClient路由过滤器源码解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Cloud Gateway WebClient路由过滤器源码解析

Spring Cloud Gateway源码解析写到过滤器篇,前面把Route、Predicate、LoadBalancer这些关键环节都过了一遍。这第4.8篇要聊的是真正把请求送到下游的那一棒——WebClientHttpRoutingFilter。这个过滤器的名字很长,作用其实很纯粹:拿到前面所有过滤器拼接好的目标地址,用WebClient把请求发到下游,再把下游的响应原样带回客户端。很多人天天用网关,却没意识到网关里其实躺着两个平行的路由过滤器,默认跑的是NettyRoutingFilter,而本篇的主角是在你把网关切到WebClient模式时才顶上来。这篇我会从它在过滤器链里的定位说起,然后逐步拆源码,中间把和NettyRoutingFilter的对比、切换实操、常见坑一次讲清楚。想搞懂网关请求全链路、或者正在考虑切换网关HTTP客户端的开发者,这篇可以直接参考。

1. 路由过滤器的定位:请求转发链路里的最后一棒

1.1 先捋一遍过滤器链的完整走向

Spring Cloud Gateway的请求处理不是从路由过滤器开始的。一个请求进来后,会经过一串全局过滤器,各司其职:

  • RouteToRequestUrlFilter:从当前路由定义里取出目标URI,拼上原始请求的路径和查询参数,生成最终的GATEWAY_REQUEST_URL_ATTR属性。
  • ReactiveLoadBalancerClientFilter:如果路由URI是以lb://开头,它负责通过负载均衡器拿到真实的服务实例地址,把lb://替换成http://ip:port。
  • 各种业务GatewayFilter:改路径、加header、重试、限流,按用户配置依次执行。
  • 最后才是NettyRoutingFilter或WebClientHttpRoutingFilter:真正把请求发出去。

所以路由过滤器是整个链条的末端执行者。前面所有过滤器做的铺垫,最终都会沉淀成一个具体的URL和一组请求头,到路由过滤器这里只需要照单发货,不需要再纠结业务逻辑。

这里有个容易混淆的点:RouteToRequestUrlFilter负责设置目标URL,但它不做转发;真正动手发请求的是RoutingFilter。负载均衡过滤器同样是只改写目标地址,不负责网络请求。理解了这个分工,再回头看各种“网关请求没发出去”的排查案例,定位会快很多。

1.2 为什么会有两个RoutingFilter

Spring Cloud Gateway默认的HTTP客户端是Reactor Netty,所以默认情况下容器里注册的是NettyRoutingFilter,它直接操作Reactor Netty的HttpClient把请求发出去。

但同时,Spring Cloud Gateway也允许你把HTTP客户端换成Spring WebClient。为了支持这种模式,源码里写了一个平行的WebClientHttpRoutingFilter。它的职责、所处环节和Netty版本完全一致,唯一的差别是:它用WebClient对象发请求,而不是直接调用HttpClient。

打个比方,同样是从A地送快递到B地,Netty版本是骑手自己开车直接送,WebClient版本是骑手把件交给配送平台,由平台统一派车。最终件都能到,但中间多了一层抽象。

这样设计带来的一个实际问题:两个路由过滤器同时存在时,谁来转发?答案是靠GATEWAY_ALREADY_ROUTED_ATTR这个属性排他。谁先执行完setAlreadyRouted,谁就把请求认领了,后执行的过滤器发现isAlreadyRouted为true,直接放行,不再转发。

1.3 WebClientHttpRoutingFilter的order

WebClientHttpRoutingFilter实现了GlobalFilter, Ordered接口,默认order是Ordered.LOWEST_PRECEDENCE。

最低优先级是故意设计的。路由过滤器应该排在所有业务过滤器之后执行,确保路径改写、header修改、负载均衡这些逻辑都已完成。如果order设得太靠前,后面的过滤器还没改完URI,请求就被发出去了,前面所有工作等于白做。

NettyRoutingFilter同样也是最低优先级。两个过滤器的order相同并不会导致冲突,因为正常项目里只有一个真正生效。如果不小心把两个都放进容器,可以通过两个配置项调整顺序:

  • spring.cloud.gateway.netty.routing-order
  • spring.cloud.gateway.webclient.routing-order

这两个配置分别控制两个路由过滤器的优先级,日常排查时偶尔用得上。

2. 源码骨架:WebClientHttpRoutingFilter怎么把请求发出去

2.1 构造器:WebClient.Builder和HttpClient的搭配

先看构造器部分。这段代码是整个过滤器的基础:

public class WebClientHttpRoutingFilter implements GlobalFilter, Ordered { private final WebClient client; private final List<HttpHeadersFilter> headersFilters; private int order = Ordered.LOWEST_PRECEDENCE; public WebClientHttpRoutingFilter(WebClient.Builder webClientBuilder, HttpClient httpClient, List<HttpHeadersFilter> headersFilters) { this.client = webClientBuilder .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); this.headersFilters = headersFilters; } @Override public int getOrder() { return order; } // ... }

很多人以为用WebClient就不走Netty了,这是个常见误解。这里只是把Reactor Netty的HttpClient包装成ReactorClientHttpConnector,再交给WebClient.Builder。WebClient底层的网络传输依然依赖Reactor Netty。

也就是说,“WebClient模式”只是换了一层API抽象,不是换了一个网络引擎。这样设计有一个直接好处:spring.cloud.gateway.httpclient.*下那些连接池、超时、TLS配置在WebClient模式下依然有效,因为底层还是同一个HttpClient对象。切换模式不会让原来调好的网络参数失效。

2.2 filter方法的前置判断

核心入口是filter方法。它的前半段是一个很典型的“条件不满足就放行”结构:

@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); String scheme = requestUrl.getScheme(); if (isAlreadyRouted(exchange) || (!"http".equals(scheme) && !"https".equals(scheme))) { return chain.filter(exchange); } setAlreadyRouted(exchange); // 后续转发逻辑 }

三个关键点拆开讲:

第一,GATEWAY_REQUEST_URL_ATTR是必经属性。如果前面的过滤器链路没有设置它,这里直接抛异常。正常链路能走到路由过滤器,说明这个属性已经被正确组装出来了。

第二,只处理HTTP和HTTPS协议。如果路由URI是lb://service-a且前面没有做负载均衡解析,scheme是lb,这里不会傻乎乎地去请求一个lb协议地址,而是放行给后续过滤器。这个判断是路由过滤器的重要保护机制。

第三,isAlreadyRouted配合setAlreadyRouted实现幂等。同一个请求在多个路由过滤器之间只会被第一个真正转发,后面的直接跳过,避免重复发送请求。

2.3 构建WebClient请求

前置判断通过后,开始组装实际请求:

ServerHttpRequest request = exchange.getRequest(); BodyBuilder builder = client.method(request.getMethod()).uri(requestUrl) .headers(httpHeaders -> { HttpHeaders filtered = HttpHeadersFilter.filter(this.headersFilters, exchange); httpHeaders.putAll(filtered); });

这里有两个容易被忽略的细节。

一个是client.method(request.getMethod())。它会根据原始请求的HTTP方法构造对应的WebClient请求,返回值是WebClient.RequestBodySpec类型。注意这里只是构造请求描述,还没有真正发送。

另一个是header过滤。构造器里有一个headersFilters列表,里面装满了HttpHeadersFilter实现。最典型的是RemoveHopByHopHeadersFilter,负责清除Connection、Keep-Alive、Transfer-Encoding、Upgrade这类逐跳头。

为什么要清除?因为网关和浏览器之间、网关和下游之间,其实是两段独立的HTTP会话。浏览器发给网关的Connection: keep-alive对下游没有意义,甚至会干扰下游对连接状态的判断。强行转发这些header,可能让连接管理出现混乱。这是HTTP代理模型中一个容易踩坑的细节。

2.4 请求体的处理:DataBuffer流式转发

请求体是路由转发里最容易写错的地方。WebClientHttpRoutingFilter的处理方式是直接用BodyInserters.fromDataBuffers(request.getBody())把原始请求体以DataBuffer流的形式交给WebClient:

Mono<Void> responseMono; if (请求体非空) { responseMono = builder.body(BodyInserters.fromDataBuffers(request.getBody())) .exchange() .flatMap(response -> { /* 处理响应 */ }); } else { responseMono = builder.exchange() .flatMap(response -> { /* 处理响应 */ }); }

流式转发意味着请求体不会在内存里被完整读一遍再发出去,而是网关收到多少就往下游写多少。上游一个数据块到达,下游就发送一块。这种方式内存占用低,特别适合大文件上传场景。

这也是为什么在自定义过滤器里消费request.getBody()是很大的坑。一旦你读了body,后面的路由过滤器就读不到完整数据了。ServerHttpRequest.getBody()返回的是一个Flux<DataBuffer>,这个流只能订阅一次。如果你非要提前读body做鉴权或日志,务必要用ServerHttpRequestDecorator把请求体缓存重组后再往后传。

2.5 响应回写:把下游结果原样还给客户端

WebClient发请求后拿到ClientResponse,接下来要把它翻译回网关的ServerHttpResponse:

.flatMap(response -> { ServerHttpResponse serverHttpResponse = exchange.getResponse(); serverHttpResponse.setStatusCode(response.statusCode()); HttpHeaders filtered = HttpHeadersFilter.filter( response.headers().asHttpHeaders(), exchange, HttpHeadersFilter.Type.RESPONSE); serverHttpResponse.getHeaders().putAll(filtered); return serverHttpResponse.writeWith(response.bodyToFlux(DataBuffer.class)); })

逻辑很朴素:状态码直接照搬,响应头过滤后复制,响应体用bodyToFlux(DataBuffer.class)流式写回客户端。

但这个流程里藏着不少细节。response.headers().asHttpHeaders()拿到的是一组原始响应头,可能带着Transfer-Encoding: chunked或者Content-Length。直接把这些头原封不动写到网关响应上,可能和网关自身的编码逻辑冲突。所以源码里在写响应头之前,会额外做一层HttpHeadersFilter.filter(..., Type.RESPONSE),把响应侧相关的逐跳头清掉。

经验之谈:如果你在自定义过滤器里想提前读下游响应体,请务必小心。一旦bodyToFlux被消费,后面就什么都没有了。最稳的做法是在路由过滤器之后再挂过滤器,通过ServerHttpResponseDecorator包装响应,而不是提前消费响应体。

3. 完整链路:一个HTTP请求怎么从这个过滤器穿过去

3.1 入口条件:GATEWAY_REQUEST_URL_ATTR是谁设的

要理解WebClientHttpRoutingFilter,必须先理解它的输入从哪来。整个链路的前半段基本都是在给这个属性做铺垫。

RouteToRequestUrlFilter首先根据当前路由的uri拼出目标地址。比如路由配置:

spring: cloud: gateway: routes: - id: demo-route uri: http://downstream-server:8080 predicates: - Path=/demo/**

请求路径/demo/order进来后,RouteToRequestUrlFilter会把下游地址拼成http://downstream-server:8080/demo/order,写入GATEWAY_REQUEST_URL_ATTR。

如果路由URI是lb://service-a/api,则轮到ReactiveLoadBalancerClientFilter登场。它从注册中心拿实例,把URI重写成http://192.168.1.10:8080/api,同样更新这个属性。路径过滤器如RewritePathGatewayFilterFactory则是直接改request.getURI(),影响最终的拼接结果。

走到路由过滤器这里时,读出来的requestUrl已经是一个可以直接请求的完整URL,不需要再处理任何业务逻辑。

3.2 有body和无body的分支差别

前面说过,源码里根据请求体是否存在分了两个分支。

无body分支相对简单:

responseMono = builder.exchange().flatMap(response -> { ServerHttpResponse serverHttpResponse = exchange.getResponse(); serverHttpResponse.setStatusCode(response.statusCode()); // 过滤响应头 return serverHttpResponse.setComplete(); });

注意这里的setComplete()和有body分支的writeWith(...)不同。无body分支不需要再写数据,直接完成响应即可。

有body分支则会把请求体的DataBuffer流作为body传进去,再走exchange()拿到响应。实际调试中,如果你怀疑网关没有正确转发请求体,可以重点看一下日志里走的是哪个分支。

3.3 响应异常:超时、断连、重置

WebClient模式下的异常传播和Netty模式有些差异。如果下游迟迟不响应,最终会在Reactor Netty层面触发超时异常;如果下游中途断开连接,会触发IOException。

WebClientHttpRoutingFilter内部处理异常的方式比较克制,通常是打一条日志,然后让异常沿着Reactor链路向上传播,最终由网关的全局错误处理逻辑交给DefaultErrorWebExceptionHandler处理,返回5xx给客户端。

这意味着如果你在自定义过滤器链里提前捕获了异常,要想清楚是否会阻断路由过滤器的异常传播。实践中最安全的做法是把重试、降级这类逻辑放在路由过滤器之后的过滤器里,或者用RetryGatewayFilterFactory在转发失败时做有限次重试,而不是在上游过滤器里强行拦截异常。

4. 和NettyRoutingFilter的对比与选择

4.1 一张表看懂两种路由过滤器

维度NettyRoutingFilterWebClientHttpRoutingFilter
底层客户端Reactor Netty HttpClientWebClient(底层经ReactorClientHttpConnector走Netty)
默认是否启用默认启用需要额外依赖
orderLOWEST_PRECEDENCELOWEST_PRECEDENCE
目标URI协议http/httpshttp/https
请求体发送nettyOutbound.send(DataBuffer流)BodyInserters.fromDataBuffers
响应体转发Netty的ByteBuf转DataBufferbodyToFlux(DataBuffer.class)
配置前缀spring.cloud.gateway.httpclient.*spring.cloud.gateway.httpclient.*
适用场景默认生产WebClient API统一、跨网关复用

两者在功能上完全等效。但NettyRoutingFilter更原生,少了WebClient这层抽象,性能和可控性都更直接。WebClientRoutingFilter则在API统一性上有优势:如果团队已经习惯用WebClient写HTTP调用,网关内部转发也走WebClient,排查问题时心智模型一致。

4.2 order的坑:两个过滤器都在怎么办

如果不小心把两个路由过滤器都放进容器,由于order相同,实际执行先后取决于bean的加载顺序。又因为isAlreadyRouted的存在,谁先执行谁就吃掉请求。

正常不会出现这种局面。官方给的spring-cloud-gateway-webflux-with-webclient依赖在设计上就是用于切换模式,不会让两个过滤器同时生效。但如果你自己在自动配置里同时注册了,可能出现“网关偶尔没有转发、日志里也看不到异常”的诡异现象。

排查思路是直接给两个过滤器配置不同的order。比如:

spring: cloud: gateway: netty: routing-order: -1

这样NettyRoutingFilter优先级更高,就会稳定抢占转发权。反过来如果想让WebClient优先,就把webclient.routing-order调低。

4.3 到底要不要换成WebClient模式

从我的实践经验看,绝大多数项目不需要切换。默认Netty模式稳定、性能好、文档多,出问题也容易排查。

真正值得切到WebClient模式的场景:

  • 团队有统一的WebClient封装,希望网关内部的调用也复用这套拦截器、过滤器、指标上报逻辑。
  • 已经在WebFlux应用里大量使用WebClient,希望网关代码风格保持一致。
  • 需要利用WebClient的exchange()返回ClientResponse做更细粒度的响应处理。

如果你是冲着“换个客户端能解决某个具体bug”去的,我建议先搞清楚bug根源再动手。底层WebClient还是包着Reactor Netty,网络层面的行为不会有本质区别。切换模式解决不了的bug,大概率换了也白换。

5. 实操:把网关切到WebClient路由模式

5.1 引入依赖

在Spring Cloud Gateway项目中加一个依赖即可:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-gateway-webflux-with-webclient</artifactId> </dependency>

这个依赖会在类路径上引入WebClient相关自动配置,让网关在构建路由过滤器时选择WebClient实现。

注意:引入依赖后务必确认Spring Boot和Spring Cloud的版本与网关匹配。版本不匹配时,最常见的现象是自动配置不生效,网关仍然走NettyRoutingFilter。此时服务能跑,但你以为切到了WebClient模式,实际没有。验证方法见5.3。

5.2 配置连接池和超时

因为WebClient底层仍然是同一个HttpClient,所以超时和连接池配置沿用spring.cloud.gateway.httpclient.*前缀:

spring: cloud: gateway: httpclient: connect-timeout: 1000 response-timeout: 5s pool: type: elastic max-connections: 500

response-timeout: 5s这里需要注意,它和其他框架的超时配置不太一样。这个值是Java Duration格式,可以写5s、1000ms。下游5秒没响应,网关就会返回504。这个配置对NettyRoutingFilter和WebClientHttpRoutingFilter都生效,因为底层是同一个HttpClient配置体系。

5.3 验证当前生效的过滤器

切到WebClient模式后,怎么确认网关确实走了WebClientHttpRoutingFilter?

推荐两个方法。

第一个,加debug日志:

logging: level: org.springframework.cloud.gateway.filter.WebClientHttpRoutingFilter: DEBUG

转发一次请求后,如果日志里出现WebClientRoutingFilter相关的路由输出,说明过滤器生效。

第二个,搭一个最简单的下游服务。用本地临时起一个Spring Boot应用或者任何能返回固定响应的HTTP服务,在网关里配好路由,再通过网关调用一次,直接观察下游是否收到请求、返回是否正常。这个方法最直观,能够排除很多配置层面的干扰。

5.4 自定义Header过滤器的接入点

如果需要在转发时去掉某些自定义header,不一定要写GatewayFilter。WebClient模式下,headersFilters列表是可以扩展的。定义一个HttpHeadersFilterBean就行:

@Bean public HttpHeadersFilter myHeaderFilter() { return new HttpHeadersFilter() { @Override public HttpHeaders filter(HttpHeaders input, ServerWebExchange exchange) { // 从input里去掉或修改指定header return input; } }; }

这个过滤器会被自动配置收集到WebClientHttpRoutingFilter构造器中。它同时负责两类过滤:网关发给下游的请求头过滤(Type.REQUEST),以及下游返回给网关的响应头过滤(Type.RESPONSE)。如果只是想统一删header,这是比写一个完整GatewayFilter更精准的做法。

6. 常见问题与排查技巧实录

6.1 请求体丢失或截断

现象:下游收到的请求体不完整,或者上传大文件时下游报错。

原因:多数情况下是某个自定义过滤器提前消费了request.getBody()。网关请求体是一个Flux<DataBuffer>,这个流只能订阅一次。前面的过滤器读了,后面路由过滤器再读就什么都没有了。

解决办法:不要直接订阅request.getBody()。需要用ServerHttpRequestDecorator包装请求,把实际body缓存到内存或临时文件,再构造一个包含完整body的新请求传给chain.filter(exchange)。缓存到内存时还要注意大小限制,避免大请求体把内存打爆。

6.2 multipart上传在WebClient模式下报错

现象:切换到WebClientRoutingFilter后,multipart表单上传偶尔出现解析失败或者下游收不到字段。

原因:WebClient模式下,multipart请求体的解析链路和Netty直发模式不完全一致。网关的ServerHttpRequest.getBody()返回的DataBuffer流,在WebClient内部会重新组合,某些版本对multipart boundary的处理方式有差异。

建议:multipart流量大的网关不建议全局切换WebClient模式。如果一定要切,可以把multipart相关的路由单独保留在Netty模式,避免全局切换引发大面积问题。

6.3 响应头Transfer-Encoding残留导致客户端报错

现象:网关返回的响应里同时出现Content-Length和Transfer-Encoding: chunked,浏览器或客户端解析报错。

原因:路由过滤器把下游响应头复制到网关响应后,网关的HttpServer在写响应时又自己生成了Transfer-Encoding,出现双头。RemoveHopByHopHeadersFilter能过滤掉一部分逐跳头,但某些情况下过滤时机不对,还是会把头带出来。

排查方向:

  • 检查是否自定义了HttpHeadersFilter,并且Type.RESPONSE过滤不完整。
  • 检查下游服务是否自己设置了Transfer-Encoding。
  • 关注Spring Cloud Gateway小版本更新,这类底层编码问题的修复往往在小版本中。

通用解法是在自定义GatewayFilter里用ServerHttpResponseDecorator对响应头做最终清理,确保Transfer-Encoding和Content-Length不会同时出现。

6.4 路由日志不显示转发目标

现象:开启了debug也没看到类似“Routing to...”的日志。

原因:WebClientHttpRoutingFilter在debug级别确实比较安静。真正输出目标URL的地方在RouteToRequestUrlFilter和ReactiveLoadBalancerClientFilter。排查转发链路时,应该调这两个过滤器的日志:

logging: level: org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter: DEBUG org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter: DEBUG

日志里能看到每次请求的目标URI从哪里来、被改写成了什么。这是排查“路由转发了但下游地址不对”最常用的手段。

6.5 小心调用链agent的干扰

最后说一个比较隐蔽的问题。有些团队会在网关层接入调用链agent或日志采集工具,它们通过字节码增强方式包裹WebClient或HttpClient。一旦有这类工具,WebClientHttpRoutingFilter的某些内部逻辑可能被改写,出现“本地没问题、测试环境偶发超时”的奇怪现象。

排查时如果发现故障时间特征很明显,比如每次请求都会触发,可以先把agent临时关掉对比一次。如果关了agent问题消失,基本可以断定是字节码增强和WebClient内部实现冲突。解决方向是升级agent版本或者在agent配置里排除对WebClient相关类的增强。


我个人在实际排查中最大的体会是:路由转发这块的问题,十有八九不是RoutingFilter自身的问题,而是它前面那些过滤器把请求体消费了、把header改坏了、或者把目标地址拼接错了。WebClientHttpRoutingFilter本身只是一条“数据管道”,输入什么就转发什么。想排查干净,把debug日志打开,沿着GATEWAY_REQUEST_URL_ATTR的变化轨迹一路看下来,问题的答案通常就浮出来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:18:21

操作系统文件管理实验:混合索引与位示图实现详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:18:20

STM32+ESP8266+MQTT方案中MCU的应用范围与任务边界

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:18:06

FreeRTOS入门实战:从裸机轮询到多任务调度完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:18:06

数据与预处理:林业病虫害图像识别的精度基石

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:17:26

C++ static_cast 深度解析:类型转换的边界与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:17:25

电商订单拆分全解析:从规则引擎到逆向流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华