《Java 网络编程基础》11.1 WebSocket 握手与 Upgrade

从 linkd-ws 的 /ws/echo 升级请求出发,逐条校验 Upgrade、Connection、Sec-WebSocket-Version 与 Sec-WebSocket-Key,手写 SHA-1 加 GUID 再 Base64 的 Accept 计算,讲清 101 响应写完后协议切换点在哪里,并给出 WebSocketHandshake 的完整实现。

本节目标:能判断一个 HTTP 请求能否升级为 WebSocket,并写出返回 101 的服务端握手代码。
适用版本:JDK 25(Temurin 25.0.5+8);只使用 java.net、java.nio 与 JDK 标准库。

承接的问题

10.3 结束时,TLS 连接已经能把明文应用数据交给上层,但上层看到的仍是一条字节流:可能是 HTTP/1.1 请求,也可能是 HTTP/2 帧。WebSocket 选择了一条更省事的路:它不另开端口,也不先协商新协议,而是让客户端发一个普通的 HTTP/1.1 请求,在请求头里声明「我想升级」。服务端同意后回 101 Switching Protocols,从下一个字节开始,同一条 TCP 连接不再讲 HTTP。

linkd 的 /ws/echo 目前会落到 8.3 的路由表里,被当成普通 GET 返回 404。要把它接上,需要三件事:识别升级请求、算出正确的 Sec-WebSocket-Accept、在 101 写完之后把连接从 HTTP 处理器移交给 WebSocket 处理器。本节只做这三件事,帧的解析留到 11.2。

先把客户端请求原样摆出来:

GET /ws/echo HTTP/1.1
Host: 127.0.0.1:9090
Upgrade: websocket
Connection: keep-alive, Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://127.0.0.1:9090
Sec-WebSocket-Protocol: chat, linkd.v1

这段请求没有任何消息体。Sec-WebSocket-Key 是客户端生成的 16 字节随机数的 Base64,不是凭据;Origin 只有浏览器会带,非浏览器客户端可以不带。服务端同意升级时回:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: linkd.v1

101 没有 Content-Length,也没有消息体。它之后的所有字节都属于 WebSocket 帧,不再按 HTTP 解析。

核心机制一:Upgrade 是一个带条件的 HTTP 请求

升级请求首先得是一个合法的 HTTP/1.1 请求,然后才谈 WebSocket。服务端至少要检查五组条件:

条件合法值不满足时
方法GET400
协议版本HTTP/1.1400
Upgrade含 websocket 这个 token400
Connection含 upgrade 这个 token400
Sec-WebSocket-Version13426,并回 Sec-WebSocket-Version: 13
Sec-WebSocket-KeyBase64 解码后 16 字节400

这里最容易写错的是「含 token」被写成「字符串相等」。Connection: keep-alive, Upgrade 是合法且常见的写法,直接拿整个头值和 "Upgrade" 比较会拒绝掉正常浏览器。头名本身也不区分大小写,upgrade、Upgrade、UPGRADE 必须一视同仁。

private static boolean containsToken(String value, String token) {
    if (value == null) {
        return false;
    }
    for (String part : value.split(",")) {
        if (part.trim().equalsIgnoreCase(token)) {
            return true;
        }
    }
    return false;
}

Sec-WebSocket-Version 不匹配时不要回 400。RFC 6455 要求回 426 Upgrade Required,并在响应里告诉客户端服务端只支持 13;客户端看到 426 才会换版本重试。linkd 的本机验证结果如下:

reject1 = 400 missing Connection: Upgrade
reject2 = 426 unsupported Sec-WebSocket-Version: 8 headers={Sec-WebSocket-Version=13}

两条拒绝分别来自 Connection: keep-alive 和 Sec-WebSocket-Version: 8 的构造请求。第二条的 headers 就是 426 响应要附带的版本提示。

Sec-WebSocket-Key 要做格式校验,但不要把它当认证。它只证明「这次握手不是缓存里重放的普通 HTTP 响应」。真正决定「谁能连」的是升级前的 Cookie、Authorization 或查询参数,以及 Origin 白名单;握手本身不提供身份。

核心机制二:Sec-WebSocket-Accept 的 SHA-1 与 Base64

服务端不能把 Sec-WebSocket-Key 原样回给客户端。RFC 6455 规定的算法是:

  1. 把客户端给的 key 当成 ASCII 字符串;
  2. 拼接固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11;
  3. 对拼接结果做 SHA-1,得到 20 字节摘要;
  4. 对摘要做 Base64,结果就是 Sec-WebSocket-Accept。
public static String acceptKey(String key) {
    try {
        MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
        byte[] digest = sha1.digest((key + GUID).getBytes(StandardCharsets.US_ASCII));
        return Base64.getEncoder().encodeToString(digest);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("SHA-1 is required by RFC 6455", e);
    }
}

这段代码输入 RFC 6455 的示例 key,本机输出是:

accept = s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

输入 dGhlIHNhbXBsZSBub25jZQ==,输出 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,与 RFC 的测试向量一致。这个值可以当成握手实现的第一个单元测试。

GUID 不能省,也不能换成别的字符串。GUID 的作用是把「WebSocket 握手」和「随便一个 SHA-1」区分开,让通用代理无法仅凭 key 猜出响应。SHA-1 在这里不是用来抵抗碰撞的:攻击者本来就知道 key 和算法。它的价值是让响应依赖一个只有拿到请求头的服务端才算得出的固定变换。因此不要因为「SHA-1 已不推荐用于签名」就换成 SHA-256,换了浏览器必然拒绝。

客户端侧也会校验:它自己算一遍同样的 Accept,再和服务端返回的值比较。不一致时浏览器报 Sec-WebSocket-Accept mismatch,连接直接断开。调试时先打印客户端 key 和服务端算出的 Accept,比抓包更快。

核心机制三:101 之后的协议切换点

握手成功后,连接的「协议」变了,但 TCP 连接、缓冲区、读事件都没有变。真正需要小心的是切换的时序:

  • 101 响应必须先于任何 WebSocket 帧写出。Connection.enqueue 是按连接 FIFO 的,只要先 enqueue 101,再 enqueue 帧,顺序就成立;不要从另一个线程抢在 101 之前写帧。
  • 如果客户端把请求和第一个帧放在同一个 TCP 段里发来,HTTP 解析器消费完请求头后,缓冲区里剩下的字节就是帧。这些字节不能丢,也不能再交给 HTTP 解析器。
  • 服务端发给客户端的帧不加掩码;客户端发给服务端的帧必须加掩码。这个方向性从 101 之后立刻生效。
  • 升级失败时连接仍然是一条普通 HTTP 连接,可以回 400/426 后按 HTTP 规则关闭,不要发 WebSocket 帧。

linkd 的读循环里,切换点大致是这样一段逻辑:

WebSocketHandshake.Result result = handshake.validate(request);
HttpResponse response = handshake.response(result);
// linkd-http 先用 8.2 的 encode 写出 101,再把同一个 Connection 交给 linkd-ws:
WebSocketSessionImpl session = new WebSocketSessionImpl(
        sessionId, eventLoop, connection, WebSocketFrameCodec.server(), echoHandler);
hub.add(session);                                     // 之后才允许写帧
if (leftover.hasRemaining()) {
    session.onReadable(leftover);                     // 同段里的首帧
}

leftover 的边界来自 8.1 的 HttpParser:解析器停在请求头结束处,若没有消息体,剩余字节原样保留。8.1 的解析器如果为了找 \r\n\r\n 多读了几个字节,也必须把多读的部分留在 ByteBuffer 里,不能丢掉。这个细节在 HTTP/1.1 里只是「管线化的下一个请求」,在 WebSocket 里却会变成「首帧丢失」,表现是客户端一直等回显、服务端一直等帧。

在 linkd 里落地:WebSocketHandshake

linkd-ws 的握手类只做「校验 + 算 Accept + 生成 101」,不碰连接和线程。这样它可以被 /ws/echo、测试和后面的网关复用。

package com.plume.linkd.ws;

import com.plume.linkd.http.HttpRequest;
import com.plume.linkd.http.HttpResponse;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class WebSocketHandshake {

    private static final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    private static final String VERSION = "13";

    private final List<String> supportedSubprotocols;

    public WebSocketHandshake(List<String> supportedSubprotocols) {
        this.supportedSubprotocols = List.copyOf(supportedSubprotocols);
    }

    public Result validate(HttpRequest request) {
        if (!"GET".equals(request.method())
                || !"HTTP/1.1".equalsIgnoreCase(request.version())) {
            throw new Rejected(400, "WebSocket upgrade requires GET over HTTP/1.1");
        }
        if (!containsToken(header(request, "Upgrade"), "websocket")) {
            throw new Rejected(400, "missing Upgrade: websocket");
        }
        if (!containsToken(header(request, "Connection"), "upgrade")) {
            throw new Rejected(400, "missing Connection: Upgrade");
        }
        String version = header(request, "Sec-WebSocket-Version");
        if (!VERSION.equals(version)) {
            throw new Rejected(426, "unsupported version: " + version,
                    Map.of("Sec-WebSocket-Version", VERSION));
        }
        String key = header(request, "Sec-WebSocket-Key");
        if (key == null || !isSixteenBytes(key.trim())) {
            throw new Rejected(400, "bad Sec-WebSocket-Key");
        }
        return new Result(acceptKey(key.trim()),
                selectSubprotocol(header(request, "Sec-WebSocket-Protocol")));
    }

    public HttpResponse response(Result result) {
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("Upgrade", "websocket");
        headers.put("Connection", "Upgrade");
        headers.put("Sec-WebSocket-Accept", result.acceptKey());
        if (result.subprotocol() != null) {
            headers.put("Sec-WebSocket-Protocol", result.subprotocol());
        }
        return new HttpResponse(101, headers, new byte[0]);
    }

    public static String acceptKey(String key) {
        try {
            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
            byte[] digest = sha1.digest((key + GUID).getBytes(StandardCharsets.US_ASCII));
            return Base64.getEncoder().encodeToString(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-1 is required by RFC 6455", e);
        }
    }

    private static boolean isSixteenBytes(String key) {
        try {
            return Base64.getDecoder().decode(key).length == 16;
        } catch (IllegalArgumentException e) {
            return false;
        }
    }

    private String selectSubprotocol(String offered) {
        if (offered == null || offered.isBlank()) {
            return null;
        }
        List<String> candidates = List.of(offered.split("\\s*,\\s*"));
        for (String supported : supportedSubprotocols) {
            if (candidates.contains(supported)) {
                return supported;
            }
        }
        return null;
    }

    private static String header(HttpRequest request, String name) {
        for (Map.Entry<String, String> entry : request.headers().entrySet()) {
            if (entry.getKey().equalsIgnoreCase(name)) {
                return entry.getValue();
            }
        }
        return null;
    }

    private static boolean containsToken(String value, String token) {
        if (value == null) {
            return false;
        }
        for (String part : value.split(",")) {
            if (part.trim().equalsIgnoreCase(token)) {
                return true;
            }
        }
        return false;
    }

    public record Result(String acceptKey, String subprotocol) {
    }

    public static final class Rejected extends RuntimeException {

        private final int status;
        private final Map<String, String> headers;

        public Rejected(int status, String message) {
            this(status, message, Map.of());
        }

        public Rejected(int status, String message, Map<String, String> headers) {
            super(message);
            this.status = status;
            this.headers = Map.copyOf(headers);
        }

        public int status() {
            return status;
        }

        public Map<String, String> headers() {
            return headers;
        }
    }
}

这个类在本机跑通的三组结果:

status = 101
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: linkd.v1

reject1 = 400 missing Connection: Upgrade
reject2 = 426 unsupported Sec-WebSocket-Version: 8 headers={Sec-WebSocket-Version=13}

第一组里客户端同时提供了 chat 和 linkd.v1,服务端按自己的偏好顺序选中 linkd.v1。子协议必须从客户端提供的列表里选,不能凭空返回一个客户端没提过的名字;如果服务端一个都不支持,正确做法是不带 Sec-WebSocket-Protocol,让客户端自己决定是否继续。

linkd-app 的注册只有一行:

router.add("GET", "/ws/echo",
        request -> handshake.response(handshake.validate(request)));

路由层仍然只认识 HttpRequest -> HttpResponse。升级后的连接由谁接管,是 linkd-http 在写出 101 之后的事:它把同一个 Connection 交给 linkd-ws,并把读处理器换成帧处理器。这样 HTTP 路由不需要知道 WebSocket 的存在,/healthz 和 /books/{id} 也不受影响。

常见错误与边界

错误一:只检查 Upgrade,不检查 Connection。 有些代理会保留 Upgrade: websocket 却把 Connection 改成 keep-alive,这时连接根本没有升级。漏检的后果是服务端回了 101,客户端却仍在等 HTTP 响应,双方对后续字节的解释完全不同。两个头都要查,而且都按 token 查。

错误二:把 key 原样当 Accept,或只对 key 做 SHA-1。 前者会让浏览器报 Sec-WebSocket-Accept mismatch,后者同样失败。GUID 必须参与摘要,顺序必须是 key + GUID,不是 GUID + key。测试时直接用 dGhlIHNhbXBsZSBub25jZQ== 对 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。

错误三:在 101 之前写帧,或丢掉 leftover。 前者会让客户端把帧字节当成 HTTP 响应的一部分;后者会让客户端发在同一 TCP 段里的首帧消失。linkd 的做法是先 enqueue 101,再把 leftover 交给会话;如果读循环在 101 写出前又收到数据,也要先缓冲,不能提前解析成帧。

错误四:不校验 Origin。 浏览器允许页面脚本连任意 ws:// 地址,且会带上目标域的 Cookie。只靠 Cookie 鉴权时,恶意页面可以借用户的登录态建立 WebSocket,这就是 Cross-Site WebSocket Hijacking。升级前应检查 Origin 是否在白名单内;非浏览器客户端可以不带 Origin,是否放行要按业务决定。

String origin = header(request, "Origin");
if (origin != null && !allowedOrigins.contains(origin)) {
    throw new WebSocketHandshake.Rejected(403, "origin not allowed");
}

错误五:把 Sec-WebSocket-Version 不匹配当成 400。 客户端收到 400 不会重试其他版本;收到 426 且带 Sec-WebSocket-Version: 13 才会改版本。这个状态码差异直接影响老客户端的兼容性。

边界:本实现只支持 HTTP/1.1 Upgrade。 RFC 8441 定义了 HTTP/2 的 CONNECT 扩展,用 :protocol = websocket 升级,本卷不实现;linkd 的 /ws/echo 只监听 HTTP/1.1 的 9090。反向代理转发时还必须显式允许 Upgrade 和 Connection 头,否则请求到不了 linkd。

小结

  • WebSocket 握手是一个带条件的 HTTP/1.1 GET,不是新协议的新端口。
  • Upgrade 与 Connection 都要按逗号分隔的 token 匹配,不能做整串相等。
  • Sec-WebSocket-Accept 是 Base64(SHA-1(key + GUID)),GUID 不能省,算法不能换。
  • Sec-WebSocket-Version 不匹配回 426,并带上服务端支持的版本。
  • 101 必须先于任何帧写出;同段到达的剩余字节要交给帧解码器。
  • Sec-WebSocket-Key 不是认证;身份和 Origin 校验要在升级前完成。

握手只决定了「这条连接可以开始讲帧」。下一节把 101 之后的字节拆开:两字节头、三种长度编码、客户端掩码和 continuation 分片。

阅读导航:上一节:10.3 会话复用、ALPN 与证书校验 · 下一节:11.2 帧格式、掩码与分片 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发