简介:面向Spring Boot开发者的WebSocket安全通信资源,重点解决在Spring Boot 2.1中启用wss(WebSocket over SSL)以支持HTTPS访问的问题,涵盖SSL/TLS原理、自签名证书生成及服务端/客户端配置,适合需要实现实时聊天、行情推送等安全长连接场景的初中级开发者。压缩包共66个文件,主要包含Java源码、xml配置、jks证书、mvnw脚本及.git版本控制文件等,整体仅61KB,结构紧凑,便于直接导入项目参考。该资源已有13108人学习,实用性得到验证。通过阅读可获得完整可运行的WebSocketDemo工程,包括SSLConfig配置类、自定义WebSocket处理器、前端JavaScript连接示例,以及pom.xml依赖和keytool命令,可快速复用到实际项目,避免踩坑。
1. 为什么非要折腾 wss:从一次线上事故说起
1.1 事故现场:https 页面下的 ws 连接悄悄失败
事情发生在一个周五下午,运维同学把全站切到了 HTTPS,页面、接口、静态资源全部正常,唯独 WebSocket 连接一只报错。浏览器控制台里刷着Mixed Content: The page at 'https://xxx' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://xxx',紧接着就是net::ERR_SSL_PROTOCOL_ERROR。
当时的第一反应是"没道理啊,WebSocket 服务明明还在跑",用 curl 测后端端口也是通的。但问题很简单也很扎心:HTTPS 页面里调用 ws:// 会被浏览器直接拦截,这跟接口的 CORS 不一样,没有商量的余地。更麻烦的是,这种报错不是所有浏览器都相同,Chrome 给的是 Mixed Content 拦截,Safari 某些版本直接静默失败,线上用户那边表现为聊天室收不到消息、实时看板一动不动,后台却不报任何 500。
这就是典型的 WebSocket 配置没有跟着 HTTPS 升级走。早在项目刚启动的时候,我们图省事在代码里写死了ws://,后来全站切 HTTPS,WebSocket 却成了漏网之鱼。把 WebSocket 从 ws 升级到 wss,说白了就是用 TLS 加密这条长连接,让它在 443 端口上跑。这篇文章就把完整的 wss 配置思路、Nginx 反代细节、踩过的坑一次性讲透。
1.2 wss 和 ws 的本质差异:不止是加了个 s
很多人以为 wss 就是 ws 多打一个字母,其实底层差别不小。类比一下 HTTP 和 HTTPS 的关系就很好理解:HTTP 是明文传输,HTTPS 是在 HTTP 外面套了一层 TLS/SSL 加密;ws 同样是明文,wss 则是在 WebSocket 协议外面加了 TLS 加密。也就是说,wss = WebSocket over TLS/SSL,默认走 443 端口,而 ws 默认走 80 端口。
差异不只是"加密"这两个字。首先,浏览器安全策略要求"安全上下文"里必须使用 wss,也就是说一个 HTTPS 页面里只能发起 wss 连接,不能混用 ws。其次,wss 的握手过程和 HTTPS 一样要经过证书验证,这带来了一层额外的信任保障——你连接的服务器确实是证书持有者的服务器,中间人很难伪造。第三,很多企业内网、云平台的安全组规则默认只放开 80/443,ws 默认的 8080 端口在这种网络环境下经常被墙,而 wss 走 443 等于搭了现有 HTTPS 通道的便车,连通率更高。
在配置之前还需要明白一个现实:浏览器客户端只认wss://开头的地址,服务端是否加密取决于你用什么方式暴露端口。如果你的 WebSocket 服务直接运行在 Nginx 后面,那么真正做 TLS 握手的是 Nginx,后端应用根本不用感知证书的存在——这是目前最主流也最省事的架构,后面说的所有配置都基于这种模式。
2. 配置前必须理清的三件事:证书、代理链路和应用端口
2.1 证书选型与全链路信任
wss 的握手要先过证书这一关。证书选什么类型、放在哪里,直接决定了你的 wss 能不能被浏览器信任。
单域名证书最简单,只覆盖example.com一个域名;多域名证书可以覆盖几个不同的域名,适合 API 和 WebSocket 分布在多个域名下的场景;泛域名证书*.example.com适合子域名多的业务,价格通常也贵一些。如果你只是给自己的小项目或者测试环境配 wss,用 Let's Encrypt 这类免费证书完全够用;企业生产环境我一般建议用云厂商的付费证书或企业级证书,售后和兼容性更有保障。
证书链的问题值得单独提醒。浏览器验证证书不是只验证服务器发来的那一张证书,而是要验证整条证书链:服务器证书 -> 中间证书 -> 根证书。我们遇到过几次"配置完 wss 后 Chrome 正常、某些 App 内嵌浏览器报错"的情况,最后定位都是 Nginx 里只配了证书文件,没有把中间证书完整带上。正确的做法是配置ssl_certificate时使用包含完整链的 fullchain 文件(多数云厂商下载证书时会直接给fullchain.pem),而不是只贴一张cert.pem。
2.2 拓扑选择:Nginx 反代 vs 直接暴露应用端口
WebSocket 服务要支持 wss,摆在面前有两条路。第一条,直接在应用层做 TLS 终止,也就是让 Node.js、Java、Go 等服务端程序自己加载证书、监听 443。这种方案对独立后端应用来说可行,但有几个现实问题:你需要在每个应用里都维护一套证书;同一台服务器上如果还跑着其他 HTTPS 服务,443 端口会被这个应用独占;万一多个 WebSocket 服务分布在多个端口,直接暴露端口的行为本身就不安全。
第二条路,也是我强烈推荐的方案:Nginx 做 TLS 终结和反向代理,应用服务继续监听内网端口。流量链路是:浏览器 --wss://--> Nginx(443, 解密 TLS) --ws://--> 后端应用(8080)。后端应用拿到的还是明文 ws 请求,所以应用代码不用关心证书、不用改协议逻辑,Nginx 负责把加密全部搞定。这么做的额外好处是:证书集中管理、可以挂负载均衡、可以用 location 按路径区分不同 WebSocket 服务、还能顺手做一层访问控制和限流。
3. 手把手配置:Nginx 反向代理实现 wss
3.1 基础配置片段与逐行解释
下面这份 Nginx 配置是我压过多个项目之后沉淀下来的最小可用版本,核心部分可以直接套用:
server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; location /wss { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 3600s; } }先说listen 443 ssl,这告诉 Nginx 监听 443 并在这一层做 TLS 握手。server_name必须和证书覆盖的域名一致,否则浏览器会报证书不匹配。ssl_certificate和ssl_certificate_key指向证书文件,证书链问题就靠这个 fullchain 文件解决。
然后看最关键的部分:proxy_pass http://127.0.0.1:8080。注意这里用的是普通的http://而不是ws://,这是很多新手最容易懵的地方。Nginx 对 WebSocket 的代理本质上是 HTTP/1.1 升级协议处理,上游地址写成 http 是标准做法,Nginx 会自动处理Upgrade的转发。真正让 WebSocket 生效的魔法在下面三行:
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";为什么要这三行?WebSocket 握手靠 HTTP 请求头里的Upgrade: websocket和Connection: Upgrade完成协议切换。HTTP/1.0 默认不带长连接能力,所以必须先通过proxy_http_version 1.1启用 HTTP/1.1。如果不显式把客户端的Upgrade头透传给后端,Nginx 默认会丢掉这些头,后端收到的就是普通 HTTP 请求,永远只会返回 400 或者直接断开连接。
3.2 关键 header 参数的作用与原理
很多教程给了配置就直接复制,但是出了问题完全不知道怎么排查,这里把每个 header 的作用表列清楚:
| Header | 作用 | 漏配的后果 |
|---|---|---|
Upgrade $http_upgrade | 透传客户端的 Upgrade 头(websocket) | 后端无法识别握手请求,连接建立失败 |
Connection "upgrade" | 告诉上游连接要升级为 WebSocket | 请求被当成普通 HTTP 处理,长连接不生效 |
Host $host | 保持原始域名,后端能正确生成回调地址 | 后端拿不到真实域名,某些场景下生成错误 URL |
X-Real-IP/X-Forwarded-For | 记录真实客户端 IP | 后端日志里全是 127.0.0.1,无法做风控和地域统计 |
proxy_read_timeout | 设置 Nginx 等待后端响应的超时时间 | 默认 60 秒,WebSocket 空闲超过 60s 会被 Nginx 主动断开 |
这里面最容易踩的就是proxy_read_timeout。WebSocket 的特点是连接建立后可能长时间没有消息往来,比如一个行情订阅页面,用户挂着五分钟不动,服务器也没推送新数据。Nginx 默认的 60 秒超时机制只对普通 HTTP 短连接友好,放在 WebSocket 场景下就意味着:只要 60 秒内没有任何数据帧通过,Nginx 就帮你把这条连接断了。页面那边表现为"一段时间不操作,重新发消息就断线重连"。所以做 WebSocket 反向代理时,proxy_read_timeout和proxy_send_timeout我建议直接调到 3600 秒或更大,具体值看业务心跳间隔,稳妥起见直接设 3600 问题不大。
4. 最容易被忽略的坑:升级头、路径重写与连接超时
4.1 踩坑实录:Sec-WebSocket-Protocol 握手失败
有一次给一个 IM 项目配 wss,浏览器访问wss://example.com/wss直接报Error during WebSocket handshake: Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received。前端代码里确实指定了子协议:new WebSocket(url, 'protobuf')。
这个报错的意思是客户端要求使用某个子协议(比如protobuf、mqtt),但服务端没有在握手响应里回同一个子协议。当时后端是 Spring Boot +HandshakeInterceptor,应用层面并没有对子协议做特殊处理。排查后发现问题出在两层:第一,后端没有配置setAllowedProtocols支持前端传的子协议;第二,Nginx 层有时也会因为缓冲区的设置影响握手头透传。
解决方法是前端去掉子协议参数,或者后端在WebSocketHandler注册时显式加上setAllowedProtocols("protobuf")。如果你用的是 Spring 的WebSocketConfigurer,核心代码是这样:
@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(), "/wss") .setAllowedOrigins("*") .setAllowedProtocols("protobuf"); } }这个坑提醒了我一个通用排查思路:wss 握手失败,八成不是 TLS 的问题,而是 HTTP 升级请求没有被完整转发。TLS 只是握手的外壳,真正决定握手成功的是 HTTP Upgrade 头、Sec-WebSocket 系列头和子协议协商。把这些头挨个比对一遍,问题就基本定位了。
4.2 403、502、404 三类常见故障排查链路
wss 配置过程中最常见的异常状态码就三种,每一种的排查链路我都走通过,直接说结论。
先说 403 Forbidden。如果你在浏览器看到握手返回 403,优先怀疑两件事:第一,后端对Origin做了限制,比如 SpringsetAllowedOrigins没配置或者配了具体域名与当前页面不一致;第二,Nginx 前面还有一层 WAF 或安全组,把带Upgrade头的请求当异常流量拦了。我遇到过一种最隐蔽的情况:某云厂商的安全网关默认只放行普通 HTTPS,对Connection: Upgrade头直接返回 403,最后在网关侧加了 WebSocket 协议的放行规则才解决。
再说 502 Bad Gateway。502 说明 Nginx 能完成 TLS 握手,但连不上后端。优先检查:后端服务是否真的监听了 8080(用ss -lntp | grep 8080确认);Nginx 和后端之间有没有防火墙拦截;proxy_pass写的 IP 和端口是否与后端实际监听一致。有一次我们把后端迁移到了 Docker 容器,端口映射从 8080 改成了 18080,Nginx 配置没同步改,导致一上午都在看 502。
最后说 404 Not Found。这个通常是 location 路径匹配问题。比如前端连的是wss://example.com/wss,Nginx 里 location 写的是/,或者proxy_pass的 URI 拼接规则不对。这里有个细节:location /wss不写结尾斜杠,proxy_pass http://127.0.0.1:8080后面也不写斜杠,那么请求路径/wss会原样转发给后端;如果proxy_pass后面带了/,那/wss这个前缀就会被吃掉,后端收到的路径变成/,两种情况下后端路由配置必须对应清楚。
5. 应用侧适配:从本地 ws 到线上 wss 的平滑切换
5.1 前端动态协议拼接的最佳实践
服务端问题解决后,前端代码里写死的ws://也要改。最佳实践不是直接硬编码替换成wss://,而是动态判断协议。这样本地开发环境走ws://,线上 HTTPS 环境自动走wss://,不用为两套环境维护两套代码:
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsUrl = `${wsProtocol}${window.location.host}/wss`; const socket = new WebSocket(wsUrl);使用window.location.host替代硬编码域名还有一个好处:当域名变更或者有多套环境(测试域名、预发布域名、生产域名)时,代码不需要跟着改。同理,如果需要携带鉴权信息,可以在 URL 上带 token,或者更推荐的做法是放在Sec-WebSocket-Protocol里,但这样做要确保后端同步支持。
前端还有一个容易踩的点:WebSocket 不支持自定义 header,只能通过 URL 参数或子协议传鉴权信息。有些同学在new WebSocket(url, { headers: { Authorization: 'xxx' }})里传 header,这是行不通的,因为浏览器 WebSocket API 根本没有 headers 选项,非标准实现(某些 Android 封装、小程序 WebSocket)才是例外。统一的做法是把 token 拼到 URL 后面,后端从查询参数里取。
5.2 常见后端服务的配置要点
后端这块分两种情况。第一种,你已经用 Nginx 反代做了 TLS 终结,后端服务不需要任何 TLS 配置,按原来的方式监听 HTTP 端口即可。这种情况下,不要给后端应用再配一遍证书,否则会出现双重加密或证书端口冲突,反而徒增问题。Spring Boot 里 WebSocket 的注册代码和普通 ws 环境完全一致,不用为 wss 做额外修改。
第二种,后端服务直接面向公网暴露,不走 Nginx。这时 Node.js 的ws库需要基于 HTTPS server 创建 WebSocketServer,示例如下:
const https = require('https'); const fs = require('fs'); const WebSocket = require('ws'); const server = https.createServer({ cert: fs.readFileSync('/etc/ssl/fullchain.pem'), key: fs.readFileSync('/etc/ssl/privkey.pem') }); const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { ws.on('message', (msg) => console.log('received:', msg.toString())); }); server.listen(443);Java 生态里常见的是 Spring Boot +spring-boot-starter-websocket,如果不走 Nginx 反代,那么只需要把server.ssl.*配置补上,同时把 WebSocket 端点的注册路径和 HTTPS 端口保持一致。考虑到国内主流部署都是 Nginx 在前面挡流量,我对这两种方式的态度很明确:能用反代就不要裸奔,后端只管业务逻辑,TLS 这种脏活累活交给 Nginx 就好。
6. 验证与压测:如何确认 wss 真的稳了
6.1 浏览器控制台验证细节
配置改完,第一件事不是跑自动化脚本,而是打开浏览器开发者工具手动走一遍握手。
在 Chrome DevTools 的 Network 面板里找到 WS 标签,刷新页面后应该能看到一条类型为websocket的请求记录。点击这条记录,重点看两个地方:第一,HTTP 状态码必须是101 Switching Protocols,这代表服务器同意升级协议,握手成功;第二,点击 Messages 子标签,正常情况能看到"发送/接收"的帧列表,手动发一条消息可以验证双向通信是否打通。
还有一个细节建议同步检查:切换到 Security 面板,点击 View certificate,确认浏览器信任的证书域名和你实际访问的域名完全一致,证书有效期也没问题。很多 wss 连接"时好时坏"的问题,根源就是证书链不完整,浏览器在弱网或特定平台下校验更严格,表现就是 30% 的用户连不上。
验证过程中我还有一个习惯:随手把页面地址从https://改成http://再做一次对比测试。如果 HTTP 页面 +ws://能连,HTTPS 页面 +wss://不能连,且证书又没问题,那大概率还是请求头没有被正确透传,按第 3 节的内容逐行比对 Nginx 配置即可。
6.2 命令行工具与自动化验证
浏览器只能验证一个客户端实例,要做批量验证和压力测试,推荐用wscat这个 Node.js 命令行工具,支持 ws 和 wss 两种协议:
# 安装工具 npm install -g wscat # 连接测试,握手成功后进入交互模式 wscat -c wss://example.com/wss # 如果想在连接时带上子协议或 Header wscat -c wss://example.com/wss -H "Authorization: Bearer xxx"连接进入交互模式后,输入任意字符串回车,如果服务端能正常收到并通过socket.send()回显,就证明双向通道没问题。对于自动化场景,可以在 CI 里用 Node.js 的ws库写一段简单的连接测试脚本,循环建立 100 个连接并发送心跳,观察断开率和握手时长,形成回归用例,防止后续配置改动把 wss 弄挂。
连接稳定性的压测也要做,而且要在真实网络下做。wscat适合功能验证,大规模的连接压测建议用WebSocket 压测平台或者自行写脚本模拟多客户端并发连接。我通常会关注三个指标:握手成功率(正常情况下应接近 100%)、长时间空闲后的连接存活率(验证proxy_read_timeout是否配置正确)、高并发下后端的连接数监控(防止出现文件句柄耗尽)。这三项全部通过,wss 配置才算真正落地。
最后再分享一个小技巧:检查 Nginx 日志是定位 wss 故障最直接的入口。把 Nginx 的 error.log 级别调成 info,WebSocket 握手失败会在日志里留下明确的错误信息,是"upstream timed out"还是"upstream prematurely closed connection",一眼就能看出是 Nginx 到后端的链路问题,还是客户端到 Nginx 的 TLS 问题。配置 wss 本身不复杂,但只要理解了下层原理,任何异常都只是沿着链路一步步排查的问题。
本文还有配套的精品资源,点击获取