1. 跨域焦虑的由来与WebSocket的特殊性
跨域问题大概是前端日常开发里被讨论得最多、但误解也最多的话题之一。只要你的页面不是纯静态演示,几乎一定会遇到一次、两次甚至无数次的跨域报错。网上讲跨域的文章大多集中在 fetch、XHR、JSONP 这一条线上,读完之后你大概知道要加Access-Control-Allow-Origin,知道要发预检请求,知道Cookie需要设置credentials。这些内容当然有用,但如果你做的项目里带着一个实时通信模块,你会很快发现:WebSocket 的跨域行为,和 AJAX 根本不是一回事。
很多人第一次踩坑是这样的:前端页面部署在https://app.example.com,后端单独起了 WebSocket 服务在wss://chat.example.com:8080,然后在前端里写new WebSocket("wss://chat.example.com:8080/ws")。结果打开页面,控制台直接给你一句WebSocket connection to 'wss://chat.example.com:8080/ws' failed,或者后端日志里一堆 403。这时候你大概率会先去翻 CORS 配置,加了Access-Control-Allow-Origin,发现没用;又去猜测是不是端口号的问题,然后开始怀疑人生。
这篇文章我想把 WebSocket 这条线单独拉出来讲清楚。你会看到 WebSocket 的“跨域问题”在底层机制上就和 XHR/fetch 不同,浏览器不会因为你跨域就去拦截连接,真正的拦截点往往在服务端。文章会从同源策略和 CORS 的原理讲起,然后进入 WebSocket 的握手细节、服务端和网关配置、前端封装与鉴权、以及常见问题的排查手段。适合正在做前后端联调的前端同学,也适合需要给前端提供 WebSocket 服务的后端同学,看完之后你应该能明确回答一个问题:WebSocket 到底需不需要处理跨域,如果需要,处理在哪个环节。
2. 从同源策略到CORS:先把一套规则吃透
2.1 同源策略到底在防什么
同源策略不是一套写在 HTTP 协议里的强制规则,而是浏览器实现的一种安全约定。它默认了“来自同一个源”的页面之间可以互相信任,可以互相读取 DOM、共享 Cookie、自由发起请求;而不同源之间,这些能力都要被限制。所谓的“源”,指的是协议、域名、端口三者都一致。http://a.com:80和https://a.com:80不同源,http://a.com和http://a.com:8080也不同源,别以为域名一样就万事大吉。
你可以把同源策略想象成一座大楼的门禁系统。同一层楼的人(同源页面)可以互相串门、共用会议室(读写 DOM、读 Cookie),而不同楼层的人想进别人的楼层,必须走一套访客登记流程——这就是 CORS。访客登记流程不是把整栋楼锁死,而是由被访问的那层楼的管理员决定“放行”还是“拒绝”。所以,CORS 本质上就是门禁的放行规则,它需要服务端配合,因为只有服务端才清楚哪些源是可信的。
2.2 CORS的两种请求:简单请求和预检请求
CORS 规则里最核心的区分是简单请求和预检请求。所谓简单请求,需要满足几个条件:方法是GET、POST或HEAD;请求头只能使用浏览器默认允许的那些字段,比如Accept、Content-Type里的application/x-www-form-urlencoded、multipart/form-data或text/plain;没有自定义头。绝大多数实际接口都不会这么“素”,只要你加了Authorization头,或者把Content-Type设成application/json,这次请求就会变成预检请求。
预检请求就是浏览器先发一个OPTIONS请求去“试水”,服务端通过响应头里的Access-Control-Allow-Headers和Access-Control-Allow-Methods告诉浏览器“你打算用的那些头和方式我认不认”,然后浏览器才真正发起业务请求。这里有个经常被误解的点:很多人看到后端日志里收到一堆OPTIONS请求,以为是某种攻击,实际上那是浏览器代为发出的预检,并不是业务代码请求了两遍。
我见过不止一次这样的误会:后端同事发现日志里来了一个OPTIONS /api/user,没有带任何业务参数,就把它拦了或者直接返回 404,结果前端就莫名其妙地报跨域。搞清楚预检机制之后,你会知道这种OPTIONS请求是挺正常的一件事,不该一刀切拦掉。
2.3 关键响应头与受信任的“白名单”
CORS 的核心是几个响应头:
| 响应头 | 作用 | 常见误区 |
|---|---|---|
Access-Control-Allow-Origin | 指定允许访问的源 | 不能和Allow-Credentials: true一起用通配符* |
Access-Control-Allow-Methods | 预检时声明允许的方法 | 最容易被漏配OPTIONS |
Access-Control-Allow-Headers | 预检时声明允许的自定义头 | 写了Authorization才能带 token |
Access-Control-Allow-Credentials | 是否允许携带 Cookie | 要和具体源搭配使用 |
Access-Control-Max-Age | 预检结果缓存时间 | 能有效减少预检次数 |
这里要用一个表格来对照着看,因为这几个头经常是一起出现的。其中最容易翻车的是Access-Control-Allow-Origin和Access-Control-Allow-Credentials的搭配问题。Allow-Credentials: true意味着浏览器可以带着 Cookie 一起发,但这时候Allow-Origin必须是具体的源,不能是*。很多新手图省事直接写*,结果发现不管怎么调,浏览器都报跨域,就是因为这两者冲突。
还有一个实操层面的经验:如果项目里有多个前端环境,比如本地开发、测试环境、预发布环境,最好在服务端维护一个允许的源列表,用代码动态生成Access-Control-Allow-Origin,而不是写死一个。写死的缺点是每加一个环境都要改一次配置,而且如果只写了一个源,其他环境的同事调试接口时会非常痛苦。
3. WebSocket的跨域到底是怎么回事
3.1 一次WebSocket握手,发生了什么
WebSocket 虽然叫“Socket”,但它的连接建立并不是凭空冒出来的,而是基于 HTTP 协议升级来的。客户端发一个普通的 HTTP 请求:
GET /ws HTTP/1.1 Host: chat.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://app.example.com服务端如果同意升级,就返回:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=这里面的Sec-WebSocket-Key是客户端随机生成的一个 base64 字符串,服务端收到后会把这段字符串拼上一个固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11,再做一次 SHA-1 哈希,最后 base64 编码返回给客户端。客户端验证这个结果,确认服务端确实理解 WebSocket 协议,才认为握手成功。
这个过程里最值得注意的其实是Origin头。它在普通 HTTP 请求里可能只是个参考信息,但在 WebSocket 握手时,服务端完全可以拿它做校验。浏览器在发起 WebSocket 握手时,会自动把当前页面的源放进Origin头,这个行为和 fetch/XHR 暴露Origin是一致的,只不过 WebSocket 的握手不会被 CORS 预检机制拦截。
3.2 浏览器拦不拦WebSocket?答案可能出乎意料
这是整篇文章最重要的一句话:现代浏览器不会因为跨域而阻止 WebSocket 连接建立。也就是说,你在https://app.example.com的页面里可以随便new WebSocket("wss://chat.example.com/ws"),只要服务端愿意接受,连接就能建立,浏览器不会在执行层面阻挡你。
这听起来好像跟“跨域问题”矛盾,但实际上它才是 WebSocket 跨域问题的本质所在。因为浏览器不管,所以“能不能连”这个决定权完全被交给了服务端。如果服务端不做任何检查,任何恶意网站都能通过一段简单的 JS 脚本连到你的 WebSocket 服务,然后订阅实时消息、发送指令、甚至鉴权通过后的敏感数据都会被第三方页面拿到。这个攻击面在业界有个共识,就是类似于 CSRF 的风险:用户在不知情的情况下,浏览器替他发起了一个跨站连接。
所以你会看到很多成熟的 WebSocket 服务端框架都会提供Origin校验的拦截器。比如 Spring 的HandshakeInterceptor可以在握手阶段检查Origin,如果不在白名单里就直接返回 false,拒绝握手。这其实就是“服务端视角的跨域处理”,它和 CORS 响应头有几毛钱关系,又有很大区别。
3.3 给WebSocket配CORS头有用吗
先说结论:给 WebSocket 握手所在的那个 HTTP 接口配Access-Control-Allow-Origin,对浏览器来说基本不起作用。因为浏览器的 WebSocket 握手不走 CORS 预检,也不看 CORS 响应头,这跟 fetch 的完整校验流程完全不同。真正起作用的是服务端自己的 Origin 校验逻辑。
但这里有一个容易混淆的点:Nginx 或者网关层配置了 CORS 头,有时看着好像“解决”了 WebSocket 连接问题,其实解决的是同域名下其他 HTTP 请求的跨域,或者刚好把端口、证书之类的问题一起处理了。如果你遇到了“WebSocket 连不上,加了一堆 CORS 头之后好了”的情况,十有八九是修改配置的过程中动了其他变量,比如把Upgrade头补上了,或者把代理超时时间调大了。
所以我的建议是:不要在 WebSocket 的问题上沿用“加 CORS 头”的思维定式。遇到 WebSocket 连不上,先看网络层、再看握手请求头、再看服务端是否拒绝,最后才看源校验。
3.4 子协议Subprotocol能帮上什么忙
WebSocket 里的子协议(Subprotocol)是一个容易被忽略的功能。客户端可以在握手请求里带一个Sec-WebSocket-Protocol头,声明自己想用哪个应用层协议,服务端可以选择其中一个返回,也可以直接拒绝握手。
实际项目里用子协议有两个典型场景。第一种是协议协商,比如graphql-ws表示这个连接要走 GraphQL over WebSocket 的消息格式,mqtt表示要走 MQTT 协议,客户端和服务端通过这个头把“接下来聊什么语言”先对齐。第二种是拿它传递标识信息,因为浏览器端WebSocketAPI 不能自定义普通 HTTP 请求头,有人就想到把 token 放在Sec-WebSocket-Protocol里带过去,服务端从握手请求头里取出来做鉴权。
不过我不太推荐把 token 放在子协议里,主要是因为子协议的值在 RFC 里有字符约束,token 往往带有特殊字符,处理起来很麻烦,而且调试工具里也不直观。更常见的做法是放在 URL query 里,或者直接让浏览器带 Cookie。具体怎么选,会在后面的实战部分展开。
4. 服务端和网关的跨域实战配置
4.1 Nginx反代WebSocket:最容易漏配的两行头
如果你的部署架构里有 Nginx,那 WebSocket 的跨域问题往往会在 Nginx 这层被放大。前面说过,WebSocket 握手依赖Upgrade和Connection两个请求头,而 Nginx 作为反向代理时,默认并不会自动把这两个头传给后端。如果漏配了,后端根本看不到升级请求,只会当成普通 GET 请求处理,返回 200 或 404,前端自然连接失败。
一个能正常工作的反代配置长这样:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl; server_name chat.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; location /ws { proxy_pass http://backend_websocket_group; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这里有两个点值得单独说。一是map那段,目的是把Connection头在普通请求里保持close,在升级请求里变成upgrade,避免把错误的值传给后端。二是proxy_read_timeout,如果你不配,默认只有 60 秒,也就是说一个 WebSocket 连接 60 秒没消息就会被 Nginx 断开,前端就会莫名其妙地频繁断线。我见过太多“为什么 WebSocket 每 60 秒掉一次”的问题,原因就是这个参数没调。
另外,如果前端页面是https页面,而 WebSocket 地址是ws://,浏览器会直接拦截,因为这是混合内容安全策略。解决办法是把 Nginx 同时监听 443 并把/ws反代到后端的 WS 服务,前端统一使用wss://chat.example.com/ws连接。这样既解决了证书问题,也把端口打平到 443,防火墙策略也更干净。
4.2 Spring Boot整合WebSocket时的CORS与鉴权
后端如果是 Java 技术栈,最常见的做法是用 Spring WebSocket。这里有个容易踩的版本坑:Spring 5.3 之前,注册端点的addAllowedOrigins方法不支持通配符,只能写具体源;5.3 之后提供了addAllowedOriginPatterns,可以支持*这种通配写法。如果你用的版本比较老,又嫌维护源列表麻烦,可以考虑统一升级版本或者通过HandshakeInterceptor自己实现校验。
一段典型的配置代码:
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(), "/ws") .addInterceptors(new MyAuthInterceptor()) .setAllowedOriginPatterns("https://*.example.com"); } }HandshakeInterceptor可以在握手前后插入逻辑,我一般在这里做两件事:一是检查Origin是否在白名单里,二是从 URL query 或者 Cookie 里提取 token 做鉴权。
public class MyAuthInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { // 校验 Origin String origin = request.getHeaders().getOrigin(); if (!isAllowedOrigin(origin)) { return false; } // 校验 token String token = request.getURI().getQuery() != null ? extractToken(request.getURI().getQuery()) : null; if (token == null || !isValidToken(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } return true; } }注意beforeHandshake返回false时,服务端会直接拒绝握手,前端那边表现就是连接失败。如果你想要前端看到明确的错误,比如 401,可以在ServerHttpResponse里设置状态码,浏览器控制台会显示Unexpected response code: 401,这样排查起来会省很多力气。
另外,如果项目里还集成了 Spring Security,那么除了 WebSocket 自己的拦截器,还要在 Security 的过滤链里放行/ws这个地址,否则请求根本到不了 WebSocket 处理层。这也是一个非常常见的联调障碍。
4.3 Python反向WebSocket:后端主动连接的玩法
热度词里有个“python反向websocket”,这个说法在业界没有特别严格的定义,但常见的场景是:某段服务不在浏览器里运行,而是由一个 Python 后端服务主动扮演 WebSocket 客户端,去连接另一个 WebSocket 服务端。这时“反向”描述的是连接方向:正常情况是浏览器连服务端,现在是后端去连别人。
在这种模式下,浏览器那套同源策略和 CORS 完全不存在,因为发请求的不是浏览器,而是服务端程序。用 Python 的websocket-client库可以很轻松地做这件事:
import websocket ws = websocket.create_connection( "wss://target.example.com/ws?token=xxx", origin="https://trusted.example.com", timeout=10 ) ws.send(json.dumps({"type": "ping"})) print(ws.recv()) ws.close()这里origin参数可以手动指定,因为服务端程序不受浏览器限制,理论上爱填什么都行。但实际业务中,如果你对接的是别人的服务,对方很可能会校验Origin,这时候你应该填自己的业务域名,而不是伪造一个。从这个视角再看前端跨域,你会发现它本质上就是一个“信任关系”的建立过程,谁在发起请求、有没有能力伪造请求头、对方信不信任你,这些才是核心。
这种“后端代连”的模式也被不少人用在网关方案里:前端只连接自己的后端网关,由网关去连目标 WebSocket 服务,外部服务的变化被隔离在后端,前端只需关心一条稳定的连接。好处是前端的跨域、证书、鉴权问题都大大简化;坏处是链路上多了一跳,如果目标服务的消息量很大,网关会变成瓶颈,需要谨慎评估。
4.4 用同源策略的“妥协”方案:前端同源网关
如果你的场景实在不想处理 WebSocket 的 Origin 校验、跨域调试、证书问题,还有一个思路 : 前端只连自己的同源地址,后端负责转发。比如页面在https://app.example.com,前端连接wss://app.example.com/ws,Nginx 收到这个路径的请求后反代到真正的 WebSocket 服务端。因为前端面向的全部是自己的域名,浏览器这边不存在跨源问题,连Origin都只会是https://app.example.com,服务端校验也容易写。
这个方案我实际用过,比较适合那些“目标 WebSocket 服务是第三方提供”的情况。比如你的业务依赖某个消息推送平台,平台只给了你一个wss://push.thirdparty.com/socket,你不可能要求平台加白名单,那就在自己后端做一个转发服务,前端永远只连自己的域名。代价是消息实时性会多一层转发延迟,但对大部分业务来说,这个延迟完全可以接受。
5. 前端工程落地的几个关键点
5.1 原生WebSocket客户端封装:避开头不能带自定义Header的雷
前端代码里最简单的连接方式当然是直接new WebSocket(url),但真实项目里几乎没有人会在业务代码里散落一堆ws://开头的字符串。一般都会封装一个统一的连接管理器,集中处理连接建立、消息分发、错误统一上报和断线重连。
封装前你要先知道一个硬性限制:浏览器端的WebSocketAPI 不允许你自定义 HTTP 请求头。也就是说,你没法像 fetch 那样写headers: { "Authorization": "Bearer xxx" },然后在握手时把 token 带过去。能走的路只有三条:放在 URL query 上,比如wss://chat.example.com/ws?token=xxx;放在子协议里;依赖 Cookie 由浏览器自动携带。
我的建议是优先把 token 放在 URL query 里。虽然严格来说 token 放在 query 里可能会被 Nginx 等网关记录到日志,但 WebSocket 的逻辑大多在内网或者短生命周期连接里,风险可控。如果实在介意,可以用 Cookie,但要注意跨域时 Cookie 的SameSite策略,需要把 Cookie 的SameSite设成None并且Secure,否则跨域握手时浏览器不会带上它。
一个基础的封装骨架大致是:
class WsClient { constructor(url, options = {}) { this.url = url; this.options = options; this.ws = null; this.heartbeatTimer = null; this.reconnectAttempts = 0; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectAttempts = 0; this.startHeartbeat(); this.options.onOpen && this.options.onOpen(); }; this.ws.onmessage = (event) => { this.options.onMessage && this.options.onMessage(event.data); }; this.ws.onclose = () => { this.stopHeartbeat(); this.scheduleReconnect(); }; this.ws.onerror = (err) => { this.options.onError && this.options.onError(err); }; } }需要注意,onerror之后一定会跟一个onclose,所以不要在onerror里去做重连逻辑,统一在onclose里处理就可以,否则会出现重连两次的问题。
5.2 心跳保活与断线重连的思路
正常情况下,WebSocket 连接可以一直挂着,但网络环境复杂多变,服务端和网关都可能有空闲超时机制。最典型的就是前面提到的 Nginxproxy_read_timeout默认 60 秒,如果 60 秒内没有任何数据帧通过,连接就会被掐断。所以客户端和服务端之间需要一套心跳机制来保持活跃。
WebSocket 协议本身有ping和pong帧,但浏览器端的WebSocketAPI 并没有暴露直接发送ping帧的方法,所以我们通常用业务层模拟:客户端定时发送一个{"type":"ping"},服务端收到后回一个{"type":"pong"}。如果连续几次没有收到 pong,就判定连接已经死了,主动调用ws.close()再重连。
重连策略上,我不建议写死一个间隔反复重连,因为服务端如果还没恢复,客户端疯狂重连只会加重负担。通常用指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最大封顶到 30 秒左右。另外,重连时要重新拼接 query 里的 token,因为 token 可能已经过期了。
还要做一个很重要的区分:是服务端主动关闭,还是网络异常导致断开。如果服务端返回了一个明确的 close code,比如 4001 表示“token 过期”,那你就不该无限重连,而是应该提示用户重新登录。判断方法就是看event.code的值,WebSocket 的应用层 close code 范围是 3000-4999,1000 表示正常关闭,1006 是异常关闭(网络断或连接被重置)。
5.3 在WebSocket里“发POST请求”是怎么回事
热度词里有“通过websocket发送post请求”,这其实不是让 WebSocket 去发送 HTTP POST,而是在 WebSocket 的消息帧里伪装出一套类似 HTTP 的请求结构。因为 WebSocket 本身是双向通信通道,它天然没有“请求-响应”的对应关系,所以业务层需要自己定义消息格式来模拟这种语义。
一个常见的设计是:
{ "method": "POST", "path": "/api/order/create", "requestId": "uuid-xxx", "body": { "sku": "12345", "count": 1 } }服务端收到后,根据method和path找到对应的处理函数,执行逻辑,然后把结果同样包一层带requestId的消息返回。客户端通过requestId把请求和响应关联起来,这就是最朴素的“基于 WebSocket 的 RPC”了。
这种做法在实际项目里有很强的生命力,尤其是需要实时推送和请求响应并存的场景。比如一个即时通信系统里,你既要收别人的消息推送,又要主动发消息、拉历史记录、改群资料,这些操作统一走 WebSocket 通道会省掉很多 HTTP 请求的连接成本。但它要求你和后端把消息格式的规范定得非常清楚,否则联调时往往因为字段命名不一致吵半天。
5.4 wss与https混合内容问题
最后再说一个部署层面常见的问题。如果页面是https打开的,那么浏览器会禁止页面里发起ws://的不安全连接,这属于混合内容安全策略。解决办法只有一个:让 WebSocket 地址也变成wss://。所以生产环境里,WebSocket 服务一定要挂在和 HTTPS 同一层级的证书体系下,通常做法就是由 Nginx 终结 TLS,然后转发给后端的普通 WS 服务。
本地开发时如果你想用 wss 调试,也可以用mkcert这类工具给localhost生成自签名证书并信任,Nginx 里配好证书,前端用wss://localhost:443/ws连接。如果你不解决证书问题,本地用http://localhost:8080打开页面,然后连ws://localhost:8080/ws也没问题,只要页面协议和 WebSocket 协议在安全级别上匹配就行。
6. 常见问题与排查技巧实录
6.1 快速对照:这些错误码到底在说什么
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
握手直接失败,控制台Failed to connect | 网络不通、Nginx 没配 Upgrade 头、服务端拒绝 | 抓包看握手请求 |
Unexpected response code: 403 | 服务端 Origin 校验不通过 | 检查服务端白名单配置 |
Unexpected response code: 401 | token 缺失或过期 | 检查鉴权拦截器 |
| 连接建立后被频繁断开,间隔几十秒一次 | Nginx 或服务端空闲超时 | 调大proxy_read_timeout,增加心跳 |
wss://地址连不上 | 证书错误、TLS 终止没做好 | 浏览器看证书信息 |
| 页面报混合内容,拒绝连接 | 页面是 HTTPS 但用了ws:// | 改成wss:// |
这个表格不是万能仙丹,但能解决八成以上的问题。WebSocket 排错和 HTTP 排错最大的不同是:它的错误信息非常少,浏览器只会在控制台打印一句连接失败,细节几乎全部藏在服务端日志和抓包结果里,所以你需要耐着性子一层层去看。
6.2 用Postman调试WebSocket接口的小技巧
Postman 很早以前就支持 WebSocket 了,用起来比很多专门的 WebSocket 调试工具都顺手。新建请求时可以选WebSocket协议类型,然后输入完整的wss://地址和请求头。有一个好处是,Postman 里你可以随意添加自定义 Header,这正好弥补了浏览器端WebSocket不能加 Header 的缺陷。如果后端要做 token 鉴权,你在 Postman 里把 token 放在 Header 中测试,能快速确认后端校验逻辑是否正常,然后再让前端改成 query 或子协议的方式传。
实际联调时我一般这样用:先用 Postman 裸连后端,带上Origin头模拟前端环境,看能不能握手成功;如果能,再让前端去连。如果 Postman 能连上而前端连不上,说明问题大概率出在浏览器侧的证书、混合内容或 token 传参方式上;如果 Postman 也连不上,说明问题出在服务端或网络链路。这一步就能把排查范围缩小一大半。
6.3 几个好用的WebSocket测试客户端与压测脚本
除了 Postman,还有一些更轻量的工具。比如在线 WebSocket 回显服务可以快速验证你的客户端逻辑,把消息发过去,原样弹回来;开源的 WebSocket 测试客户端通常能显示帧的发送和接收时间戳,便于定位延迟问题。
如果你需要简单压测,也不一定非要上重型工具,写一个 Python 脚本就够了:
import websocket import json def on_message(ws, message): print("recv:", message) ws = websocket.WebSocketApp( "wss://chat.example.com/ws?token=test", on_message=on_message ) ws.run_forever(sslopt={"cert_reqs": 0})多开几个终端跑这个脚本,就能模拟多客户端连接,看看服务端能不能扛得住。这里有个细节:sslopt={"cert_reqs": 0}是让 Python 在本地测试环境下跳过证书校验,如果连的是生产环境,不要用这个参数,应当使用真实信任链。
6.4 从浏览器到服务端的一整套排查思路
最后,我想把 WebSocket 排错的整体思路串一遍。遇到问题先别急着改代码,按下面这个顺序确认:
第一步,打开浏览器开发者工具,切到 Network 面板,找到对应的 WebSocket 连接,查看握手请求的状态码和响应头。这一步能确认网络层通不通、Nginx 是否正常转发、服务端有没有拒绝。第二步,看 WebSocket 连接的帧列表,确认连接建立后有没有数据往来。第三步,看服务端日志,WebSocket 框架一般会打印握手成功或失败的原因,Origin 校验失败往往也会在日志里有明确提示。第四步,如果服务端日志完全没有输出,那问题很可能出在反向代理或者证书层,用curl -v或者 Postman 去测一下目标地址能不能完成 101 握手。
这套流程看起来简单,但实际执行起来非常考验耐性。很多次我以为问题在后端鉴权,查了半天发现是 Nginx 没配Upgrade头,还有一次以为前端 token 传错了,最后发现是本地白名单环境的Origin是localhost:3000,而后端正则匹配只允许*.example.com。这种“差一个字符”的问题,在跨域排查里太常见了。
做跨域和 WebSocket 这个问题绕了这么多年,我个人最深的体会是:浏览器只是那个执行规则的守门员,真正的规则制定权始终在服务端手里。与其遇到问题就到处复制粘贴 CORS 配置,不如先搞清楚当前场景到底卡在哪一层,是协议升级、网络转发、鉴权校验还是证书信任。把这些环节一个个列出来,定位速度会快很多。团队里如果能把 WebSocket 的握手头、Nginx 配置、鉴权传参方式这些沉淀成文档,后面新来的同事接手实时通信模块时会少踩无数个坑。