news 2026/10/16 1:26:47

Spring Cloud Gateway的WebSocketRoutingFilter:原理、源码与踩坑全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Cloud Gateway的WebSocketRoutingFilter:原理、源码与踩坑全解析

如果只是用网关转发普通 HTTP 接口,你大概率不会注意到WebSocketRoutingFilter这个类——它藏在 Spring Cloud Gateway 的过滤器链最末端,order 值接近Integer.MAX_VALUE,平时完全是个透明角色。但一旦某个业务系统需要通过网关反向代理 WebSocket 长连接,你绕不开它,而且很容易被它折磨到深夜。

我印象最深的一次排障:某系统的前端页面需要实时订单推送,直连后端服务一切正常,中间加了 Spring Cloud Gateway 之后就永远卡在connecting...。抓包发现网关返回的是 HTTP 200,而不是101 Switching Protocols,那一刻才意识到——网关把 WebSocket 升级请求当成普通 HTTP 请求给转发了。从那以后,我把WebSocketRoutingFilter的源码从头到尾翻了几遍,也积累了不少实战坑。这篇就把过滤器(4.6)中WebSocketRoutingFilter的原理、关键源码、过滤链时序和踩坑经验一次讲透,适合正在维护 Spring Cloud Gateway 网关、需要扩展 WebSocket 能力的开发者参考。

1. 为什么 WebSocket 不能像普通 HTTP 一样被网关直接转发

1.1 握手请求的特殊之处:既是 HTTP 又是协议切换

WebSocket 最迷惑人的一点是:它和 HTTP 并不是完全割裂的两个协议。客户端发起 WebSocket 连接时,第一步发的仍然是一个标准 HTTP GET 请求,只是请求头里多了三个关键信息:

Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Version: 13

服务端如果同意升级,会返回101 Switching Protocols,同时在响应头里通过Sec-WebSocket-Accept证明自己理解 WebSocket 协议。这个响应之后,同一个 TCP 连接上不再传输 HTTP 报文,而是传输 WebSocket 帧数据——二进制帧、文本帧、ping/pong 帧。

这个"升级"动作的本质决定了网关不能把它当普通 HTTP 请求处理。普通 HTTP 转发是请求-响应一次完成后就结束,但 WebSocket 握手成功后,连接变成一条双向长连接,网关要做的不只是转发一个响应,而是要把这条 TCP 通道"接住",让客户端和服务端在连接生命周期内持续交换数据。

可以打个比方:HTTP 请求像去餐厅点一份外卖,送完就结束;WebSocket 握手则像包了一个包间,进门打招呼(握手)之后,服务员持续给你上菜,整个过程一直不关房门。网关如果只会在门口接外卖,碰到包间客人就不知道怎么服务了。

1.2 普通路由过滤器接不住 101 响应

Spring Cloud Gateway 里负责普通 HTTP 转发的核心过滤器是NettyRoutingFilter。它的工作模式是:拿到GATEWAY_REQUEST_URL_ATTR指向的目标地址,通过 reactor-netty 的HttpClient发出请求,然后从ClientHttpResponse里取出状态码、响应头、响应体,写回给客户端。

对普通 HTTP 接口来说,这个逻辑天经地义。可对 WebSocket 升级请求来说,下游返回101之后,响应不再是"一个报文",而是"一条连接"。"读响应体写到客户端"这个动作在101场景下根本无法继续执行:响应体没有结束,连接也不能关闭,关闭了 WebSocket 直接断。

所以网关必须有一种过滤器,在NettyRoutingFilter出手之前,先把 WebSocket 升级请求拦下来,自己完成协议层面的对接。

1.3 过滤器链里 URL 重写的结果存放在哪里

要理解WebSocketRoutingFilter,还得先搞清楚一个前置概念:请求在过滤链上跑的时候,目标地址是会被多次改写的。

  • RouteToRequestUrlFilter最先出手,它把路由配置里声明的uri(可能是lb://serviceId,也可能是http://host:port)写进GATEWAY_REQUEST_URL_ATTR,并记录原始 URL。
  • LoadBalancerClientFilter发现 URI 以lb://开头时,会从注册中心选择一个真实实例,把地址改写成http://具体ip:端口。
  • 后续过滤器再从exchange.getRequest().getURI()或 exchange 属性里取最终要访问的地址。

WebSocketRoutingFilter处在过滤链靠后的位置,它拿到的请求 URI,理应是前面过滤器重写后的结果。但源码里它不只是"等别人给结果",自己还有一套负载均衡兜底逻辑——这一点后面专门展开。

2. 源码骨架:Order 值、判定条件与过滤目标

2.1 类的构造与字段

不同版本的WebSocketRoutingFilter构造参数有差异。老版本基本只有一个WebSocketService,新版本引入了LoadBalancerClient等依赖,字段大致如下:

字段作用
WebSocketService webSocketService真正执行 WebSocket 握手与隧道转发的服务
LoadBalancerClient loadBalancerClient负载均衡客户端,处理lb://前缀的地址兜底解析
Route route当前命中的路由配置,用于获取serviceId等信息

如果你在本地反编译看到构造方法签名和社区版不完全一样,不用慌,核心职责就这三件事:判断要不要接管、改 URL、转交WebSocketService处理。

2.2 getOrder 为什么取Integer.MAX_VALUE - 1

WebSocketRoutingFilter实现了Ordered接口,order 值固定为Ordered.LOWEST_PRECEDENCE - 1,也就是Integer.MAX_VALUE - 1。

这个数字有讲究。过滤链里 order 越小越先执行,它取了一个"几乎最后但又不是最后"的位置,目的有两个:

  1. 等待RouteToRequestUrlFilter、LoadBalancerClientFilter等前置过滤器把路由地址、负载均衡实例解析完成,自己再动手重写 URI。
  2. 抢在NettyRoutingFilter之前执行。NettyRoutingFilter的 order 是Ordered.LOWEST_PRECEDENCE,也就是Integer.MAX_VALUE。虽然只差 1,但这一位的差距决定了 WebSocket 升级请求会先被WebSocketRoutingFilter接管,而不是被普通 HTTP 转发逻辑截胡。

看一段简化后的核心骨架:

public class WebSocketRoutingFilter implements GlobalFilter, Ordered { private final WebSocketService webSocketService; private final LoadBalancerClient loadBalancerClient; @Override public int getOrder() { // 末尾前一位:等待前置过滤器完成路由地址重写 // 同时确保先于 NettyRoutingFilter 拦截升级请求 return Ordered.LOWEST_PRECEDENCE - 1; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { boolean isUpgrade = isWebSocketUpgrade(exchange); if (!isUpgrade) { // 不是 WebSocket 升级请求,放行 return chain.filter(exchange); } // ... 接管握手流程 } }

2.3 命中条件:GET 方法 + Upgrade 头的组合

不是所有 WebSocket 请求都会进入这个过滤器分支,源码里的判定条件在方法入口处:

boolean isWebSocketUpgrade = HttpMethod.GET.equals(exchange.getRequest().getMethod()) && "websocket".equalsIgnoreCase( exchange.getRequest().getHeaders().getUpgrade());

翻译成人话就是:

  • 请求方法必须是GET;
  • Upgrade请求头存在,且值忽略大小写后等于websocket。

有些版本还会顺带检查Connection头是否包含upgrade,但最核心的还是看Upgrade头。实际生产环境里,客户端只要是按标准实现的 WebSocket 库,这两个条件基本都能满足。但如果有人用 POST 发 WebSocket 握手、或者把Upgrade头写成了别的名字,这个过滤器就不会接管,请求会继续往后走,最终被当成普通 HTTP 转发,结果就是连不上。

所以排查"网关后面 WebSocket 连不上"这类问题时,第一件事永远是确认请求头是不是GET + Upgrade: websocket,而不是先怀疑过滤链。

3. 从原始 URI 到握手 URL:一次请求重写的完整路径

3.1 第一步:识别本次请求需要进入 WebSocket 处理分支

filter()方法入口拿到ServerWebExchange后,第一步就是做上一节说的升级请求判定。一旦命中,后续所有逻辑都围绕"如何把当前 HTTP 请求改造成一个对下游的 WebSocket 握手请求"展开。

3.2 第二步到第三步:把 http/https 换成 ws/wss

WebSocket 握手请求的 URL scheme 必须是ws://或wss://。源码里对应的工具方法我简化后大概是这个样子:

private URI changeSchemeIfIsWebSocketUpgrade(URI uri) { String scheme = uri.getScheme(); if ("http".equalsIgnoreCase(scheme)) { return UriComponentsBuilder.fromUri(uri) .scheme("ws") .build(true).toUri(); } if ("https".equalsIgnoreCase(scheme)) { return UriComponentsBuilder.fromUri(uri) .scheme("wss") .build(true).toUri(); } return uri; }

用UriComponentsBuilder重建 URI,可以保留原始 URI 的 path、query、fragment 等信息,只替换协议头。这里最重要的观点是:WebSocketRoutingFilter只负责把"HTTP 语义"改成"WS 语义",并不会帮你处理 TLS 卸载。wss://的加密通道如何终止、如何再连下游,是另一套配置问题,后面第 6 章专门说。

3.3 第四步:Host 为空的兜底——负载均衡解析

这是WebSocketRoutingFilter里最容易忽略、但在实际中非常重要的一个分支。

正常情况下,请求经过LoadBalancerClientFilter之后,URI 的 host 已经变成了具体实例的 IP。但如果你配置的路由是lb://serviceId,且某些版本或自定义过滤链导致前面的负载均衡处理没有生效,WebSocketRoutingFilter里拿到的 URI 就会出现host == null。

源码对这种情况的处理思路是:如果host为空,并且持有loadBalancerClient,那就主动用负载均衡客户端选一个实例:

if (url.getHost() == null && this.loadBalancerClient != null) { // 从路由配置里取出 serviceId,然后选择实例 ServiceInstance instance = this.loadBalancerClient.choose(serviceId); uri = changeSchemeIfIsWebSocketUpgrade( UriComponentsBuilder.fromUri(exchange.getRequest().getURI()) .host(instance.getHost()) .port(instance.getPort()) .build().toUri()); }

这个兜底逻辑的代价是:它需要route能够提供真实的serviceId。如果路由配置本身不是lb://形式,或者route字段为空,那这里就只能保留原始 URI,让后续链路自己报错。所以我的建议是:能依赖前置LoadBalancerClientFilter的正常处理路径,就不要依赖WebSocketRoutingFilter的兜底。兜底方案适合排查问题,不适合作为常规路由设计。

3.4 第五步:只保留白名单请求头

这是整篇文章里我认为最重要的一个细节:WebSocketRoutingFilter在转发握手请求时,不会把客户端的原始请求头全部透传。源码里有一个过滤逻辑,只保留几个 WebSocket 握手必需的头:

  • Host
  • Sec-WebSocket-Key
  • Sec-WebSocket-Protocol
  • Sec-WebSocket-Extensions
  • Sec-WebSocket-Version
private HttpHeaders filterHeaders(HttpHeaders headers) { HttpHeaders filtered = new HttpHeaders(); filtered.add(HttpHeaders.HOST, headers.getFirst(HttpHeaders.HOST)); if (headers.containsKey(SEC_WEBSOCKET_KEY)) { filtered.add(SEC_WEBSOCKET_KEY, headers.getFirst(SEC_WEBSOCKET_KEY)); } if (headers.containsKey(SEC_WEBSOCKET_PROTOCOL)) { filtered.add(SEC_WEBSOCKET_PROTOCOL, headers.getFirst(SEC_WEBSOCKET_PROTOCOL)); } if (headers.containsKey(SEC_WEBSOCKET_EXTENSIONS)) { filtered.add(SEC_WEBSOCKET_EXTENSIONS, headers.getFirst(SEC_WEBSOCKET_EXTENSIONS)); } if (headers.containsKey(SEC_WEBSOCKET_VERSION)) { filtered.add(SEC_WEBSOCKET_VERSION, headers.getFirst(SEC_WEBSOCKET_VERSION)); } return filtered; }

为什么这样设计?因为到这一步,请求已经不是普通 HTTP 请求了,Content-Length、Transfer-Encoding、各种业务自定义头,对 WebSocket 握手没有意义,反而可能让下游产生误解。保留Host是为了让下游虚拟主机路由正常工作;保留Sec-WebSocket-Key是让下游能基于客户端原始 key 计算Sec-WebSocket-Accept——这一步不能重新生成,否则客户端本地校验会失败;保留协议相关头是为了让握手协商继续成立。

生产环境里最容易被这个白名单坑到的场景是:你在网关后面给 WebSocket 服务做鉴权,希望把Authorization头透传给下游。结果下游收到的握手请求里根本没有这个头,业务鉴权直接失败。这个问题没有优雅的破解办法,只能在业务层换方案,例如把 token 放在 query string、cookie,或者在子协议里编码传递。这个坑我放到第 6 章细说。

3.5 第六步:交给 WebSocketService 执行握手与隧道

地址也换好了,头也过滤完了,接下来就是把请求交给WebSocketService。接口方法很简单:

public interface WebSocketService { Mono<Void> handleRequest(ServerWebExchange exchange, URI requestUrl); }

默认实现是HandshakeWebSocketService。它内部会创建一个 reactor-netty 的WebSocketClient,拿着过滤后的请求头向requestUrl发起握手请求。如果下游返回101,握手成功,它会拿到一个WebSocketSession,然后把两个方向的数据流互相接起来:

  • 客户端发来的帧数据,写回给下游;
  • 下游发来的帧数据,写回给客户端。

这个"互相透传"的过程,在代码层面就是session的inbound和outbound两个流做对接,相当于是把交换机两端接通。网关在这里扮演的已经不是一个"HTTP 代理",而是一个"双向管道"。

4. 过滤器链时序:为什么它必须排在 LoadBalancerClientFilter 之后

4.1 看清请求在过滤链上的变化过程

把整条过滤器链按 order 排开,就能看清WebSocketRoutingFilter的生存环境:

  1. RouteToRequestUrlFilter(order 约 10000):从路由配置里取出uri,放入GATEWAY_REQUEST_URL_ATTR,并记录原始请求 URL。
  2. LoadBalancerClientFilter(order 约 10150):如果uri是lb://serviceId,选择实例并重写 URI。
  3. 一堆业务GlobalFilter逐个执行。
  4. WebSocketRoutingFilter(order =MAX_VALUE - 1):识别 WebSocket 升级请求,改协议头,做负载均衡兜底,调用WebSocketService。
  5. NettyRoutingFilter(order =MAX_VALUE):处理剩下的普通 HTTP 转发请求。

这里有一个值得琢磨的细节:WebSocketRoutingFilter为什么不直接读GATEWAY_REQUEST_URL_ATTR,而是从exchange.getRequest().getURI()取地址?

原因在于,前面的过滤器很多时候通过装饰器包装了ServerHttpRequest,此时getURI()返回的就是重写后的地址;而直接读 attribute 在某些版本里拿到的可能是原始路由配置值。源码两种方式都有涉及,具体看版本。理解这一点对排查很有帮助——如果你自定义过滤器改了 attribute 但没改请求装饰器,后面的WebSocketRoutingFilter可能根本感知不到你的修改。

4.2 如果顺序被打乱会发生什么

假设你自己实现了一个GlobalFilter,order 设置得比WebSocketRoutingFilter还大,也就是排在它后面。然后这个过滤器又把 URI 改回了原始值,或者把GATEWAY_REQUEST_URL_ATTR清掉了。那WebSocketRoutingFilter已经调用完WebSocketService,你后面做什么都晚了,握手用的还是旧地址。

反过来,如果你的自定义过滤器 order 比LoadBalancerClientFilter小,在它之前就把 URI 改成了别的值,那么负载均衡解析的对象就不是你想转发的服务了。

所以我的经验是:不要轻易在过滤链的极末端、极前端去动 URI。要改目标地址,优先用RouteToRequestUrlFilter之前的标准扩展点,或者小范围自定义一个专用GlobalFilter,并明确自己的 order 位置。

我还遇到过一种诡异情况:WebSocketRoutingFilter的兜底负载均衡逻辑把请求转发到了一个随机实例,但那个实例恰好没有启动 WebSocket 服务端,导致握手一直失败。查了半天才发现,是我自己写的一个全局过滤器在lb://请求上没有正确调用负载均衡客户端,把脏 URI 一直留到了过滤链末端,触发了WebSocketRoutingFilter的兜底分支。兜底逻辑本身没错,但它把"路由解析失败"的问题掩盖成了"下游连接失败",排查方向很容易被带偏。

5. 本地复现:一个最小 WebSocket 网关路由

5.1 准备一个 WebSocket 服务端

要验证WebSocketRoutingFilter的行为,最有效的办法是本地起最小环境。服务端直接用 Spring WebFlux 写一个WebSocketHandler就行:

@Component public class EchoWebSocketHandler implements WebSocketHandler { @Override public Mono<Void> handle(WebSocketSession session) { return session.send( session.receive() .map(webSocketMessage -> { String text = webSocketMessage.getPayloadAsText(); return session.textMessage("echo: " + text); }) ); } }

再配一个简单的 WebSocket 服务端点:

@Configuration public class WebSocketConfig { @Bean public HandlerMapping webSocketMapping(EchoWebSocketHandler echoHandler) { Map<String, WebSocketHandler> map = new HashMap<>(); map.put("/ws/demo", echoHandler); return new SimpleUrlHandlerMapping(map, 1); } }

这个服务端启动后,直接访问ws://127.0.0.1:8080/ws/demo应该能正常连通。先确认直连没问题,再套网关,避免把问题带到下一层。

5.2 网关路由配置与验证命令

网关侧配置一个最小路由:

spring: cloud: gateway: routes: - id: ws-demo uri: ws://127.0.0.1:8080 predicates: - Path=/ws/**

这里注意,uri用ws://开头。如果你用lb://serviceId,则需要保证127.0.0.1:8080这个实例已经注册到了注册中心,否则负载均衡客户端选择不到实例,握手请求无法发出。

启动网关后,浏览器直接连ws://localhost:网关端口/ws/demo,或者用带--include的 curl 看握手细节:

curl -i --no-buffer \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ http://localhost:网关端口/ws/demo

关键要看两点:

  • 响应状态码是不是101 Switching Protocols;
  • 响应头里是不是有Sec-WebSocket-Accept。

如果看到的是 200,说明请求没有被WebSocketRoutingFilter接管。最常见的原因是路径没匹配上,或者Upgrade头没传过来。

我在本地调试时还会顺便看一眼网关日志里WebSocketRoutingFilter是否进入了处理分支。某些版本的日志框架会打出请求转换后的目标地址,这对确认"地址改写得对不对"很有帮助。没有日志也不怕,用抓包工具看网关到下游的握手请求,比看任何日志都直观。

6. 生产环境三个高频问题与源码级回答

6.1 "Unexpected handshake response status: 200"

这是我在网上和实际项目中见到的频率最高的问题。前端连网关,网关连下游,握手响应不是101而是200,HandshakeWebSocketService内部对状态码有严格校验,直接抛异常,异常文案大体是Invalid handshake response get status 200或者新版本里的Unexpected handshake response status: 200。

从源码逻辑反推,出现这个异常只有几类原因:

  • 下游服务本身不支持 WebSocket,或者对应路径上没有 WebSocket handler;
  • 下游返回了 200 但 body 是普通 HTTP 内容,意味着它根本没识别升级请求;
  • 网关配置的 path 转发到了错误的服务。

排查思路我很固定:先绕开网关,直连下游看能不能收到 101;能收到,再抓网关和下游之间的包,看Upgrade、Sec-WebSocket-Key是不是原样透传。这个过程不需要改任何代码,几分钟就能定位是网关配置问题还是下游实现问题。

6.2 wss 的 TLS 由谁来终止

前端通过wss://连网关,网关到下游却经常是普通的ws://,这中间有一个容易误解的点:WebSocketRoutingFilter改的是 URI scheme,它不会自动帮你做 TLS 卸载。

换句话说,如果前端请求到达网关时是https://,网关自身没有配置 TLS 证书,那https请求根本到不了过滤器链,更谈不上把 scheme 改成wss。如果网关前面还有一层负载均衡设备或反向代理,由它终结 TLS,再把明文 HTTP 请求转发给网关,那网关拿到的就是http://,后面走ws://没问题。

所以配置wss之前,先理清楚 TLS 在哪一层终结:

  • wss://由网关前面的独立负载均衡器终结,网关内部用ws://,这是最常见的部署;
  • 网关自己终结 TLS,那WebSocketRoutingFilter拿到的最初 URI 是https,它会把 scheme 改成wss往下游发,下游也必须支持wss或 SSL 卸载,否则连不通。

不要在"网关改个 scheme 就能自动处理所有加密"的幻想,这是它做不到的。

6.3 自定义鉴权 Header 被丢得干干净净

第 3.4 节提到的filterHeaders白名单机制,带来的直接后果就是:你在握手请求里加的任何自定义 Header,比如Authorization、X-Auth-Token,在网关转下游时都会被丢弃。很多团队第一次接 WebSocket 时都会在这个地方踩坑。

既然根因在源码,那就别在握手 Header 层面硬顶。我在生产环境里的替代方案有三种:

  1. 把 token 编码进Sec-WebSocket-Protocol子协议。例如前端携带Sec-WebSocket-Protocol: myauth, chat,下游从子协议里解析 token。前提是下游服务要能容忍并解析额外的子协议值,否则协商可能失败。
  2. 把 token 放在 query string 里。比如ws://网关/ws/demo?token=xxx,下游从 query 参数里取。这个方案简单直接,但 token 会出现在 URL 日志里,并且对 query 长度敏感。
  3. 不在握手阶段做鉴权,等 WebSocket 连接建立后,通过业务帧做应用层鉴权。这是最干净、最兼容网关代理链路的做法,缺点是连接建立后,如果 token 非法,需要主动关闭连接。

我倾向于在可靠时用第三种,在简单场景下用第二种。第一种方案我用的最少,因为子协议的协商机制在不同语言实现里差异不小,很容易引入新的兼容性问题。

最后补一句实操心得

把WebSocketRoutingFilter的代码翻完,我最大的收获反而不是源码本身,而是对"过滤器链是一个语义逐层衰减的过程"的理解:越是靠后的过滤器,越要接受前面过滤器留下的"半成品",不能指望自己能把所有东西都修正回来。

所以我的建议永远是:让RouteToRequestUrlFilter和LoadBalancerClientFilter把路由地址处理干净,让业务过滤器专心做自己的事,WebSocket 请求交给WebSocketRoutingFilter时,应该已经是一个地址清晰、协议明朗的请求,而不是一个满是坑的半成品。排查问题时也按这个顺序来:先确认下游能回 101,再确认网关路径命中,最后抓包看Upgrade和Sec-WebSocket-Key是否原样透传。这套流程我目前还没失手过,希望能帮你省下几个深夜。

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

电阻与电源等效变换全解析:从串并联化简到Y-Δ变换

/* 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:26:38

前端定时器深度解析:从事件循环到实战避坑指南

/* 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:26:35

源码编译与定制实战:从配置参数到二进制裁剪的完整指南

/* 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:26:34

串联电池组均衡管理方案:MP2642GR-Z与PIC18F47K42的工程实践

/* 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:26:32

PTA 7-1 统计单词数量并输出最后一个字符:vector C++ 实战解析

/* 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:25:50

SSD生命周期预测实战:从SMART属性到RUL建模与落地

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

作者头像 李华