如果只是图省事我大概率会直接引入Spring的那套东西让WebSocket服务器接收音频文件变成一个注解的事。但有个现实场景逼着我换了个思路某内网交付项目运行环境只有基础JDKMaven私服和外部网络全都没有任何第三方jar都进不去。既然WebSocket本质上就是“一次HTTP升级握手一组二进制帧”JDK自带的ServerSocket、DataInputStream、MessageDigest、Base64完全够我把它写出来。于是我用一个不依赖任何第三方库的Java文件实现了一个能接收浏览器实时推送音频的WebSocket服务端编译运行都只要一条命令。这篇文章把协议要点、完整代码、联调方法和踩坑记录都摊开来写给同样被离线环境困住的人一个参考。1. 为什么不用Spring Boot/Netty坚持零依赖手写1.1 我眼里的“零依赖”到底是什么先说清楚我对“零依赖”的定义服务端只用到JDK标准库中的java.net、java.io、java.security、java.util这些包不引入任何第三方jar不依赖任何框架容器。整个项目只有一个.java文件编译敲一条javac运行敲一条java不需要Maven、Gradle也不需要配置文件。你可能觉得这是没事找事。但真到了我前面说的那种现场机器是客户提供的安全策略不准联网安装东西Maven私服不存在外网下载jar要层层审批有些目录连写权限都受限。这时候“只靠一个JDK就能跑”就是硬通货。我不需要跟运维解释为什么项目要拉二十个jar包我只需要说“给我一个装了JDK的Windows/Linux机器其他什么都不用装。”1.2 常见WebSocket方案的取舍对比我整理了一张表把常见的Java WebSocket方案放在一起比过之后你会发现“纯JDK手写”不是什么情怀而是特定约束下的最优解。方案外部依赖启动成本适合场景Spring Boot WebSocketspring全家桶、内嵌容器、starter项目结构重启动链路长已有Spring体系的大型服务Netty WebSocket编解码器netty jar概念多线程模型复杂高并发、海量长连接TyrusJSR 356实现tyrus jar需要容器配合依赖规范实现行为偏重容器内标准应用纯JDK手写无一个文件搞定离线受限环境、学习协议这里必须说明Netty和Spring在它们的主场确实更强背压处理、连接管理、框架生态都不是手写代码能比的。但反过来如果需求只是“接收几个浏览器推过来的音频文件存到本地”用Netty纯属杀鸡用牛刀引入的复杂度和jar体积反而成了负担。1.3 手写一遍协议才是你的抛开环境限制不谈手写一遍WebSocket服务端还有一个非常实际的好处你被迫把RFC 6455的协议细节啃一遍。以后再遇到线上握手失败、帧解析错乱、数据长度对不上这类问题你不用对着框架源码黑盒猜直接就能从字节层面定位。框架帮你封装了细节但也把问题藏在了黑盒里。WebSocket服务端说穿了只需要四样东西监听端口的ServerSocket、读取HTTP升级请求的字节流、计算握手密钥的MessageDigest和Base64、解析二进制帧的DataInputStream。JDK全部自带零依赖手写完全是可行的。2. WebSocket只需要看懂两样东西握手和帧结构2.1 握手一次有点特殊的HTTP请求WebSocket连接建立的前奏是一次普通得不能再普通的HTTP GET请求只不过带上了Upgrade头。浏览器发过来的原始请求长这样GET /audio HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端要做的不是返回200而是返回101同时算出一个Sec-WebSocket-Accept响应头HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo这个Sec-WebSocket-Accept的计算公式是固定的把客户端发来的Sec-WebSocket-Key拼上一个写死的GUID字符串做SHA-1散列再Base64编码。GUID就是258EAFA5-E914-47DA-95CA-C5AB0DC85B11RFC 6455里写死的值任何实现都一样。private static String computeAcceptKey(String clientKey) throws NoSuchAlgorithmException { MessageDigest sha1 MessageDigest.getInstance(SHA-1); byte[] digest sha1.digest((clientKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) .getBytes(StandardCharsets.ISO_8859_1)); return Base64.getEncoder().encodeToString(digest); }这个握手设计的精妙之处在于挑战-响应机制客户端能算出Sec-WebSocket-Key期望的Accept值证明它懂RFC 6455服务端也能验证这一点避免中间缓存的意外干扰。你需要额外校验的还有Sec-WebSocket-Version必须等于13以及Connection头必须包含Upgrade否则直接回400。2.2 帧结构看懂这张位图协议就懂了一半握手完成之后后面传输的所有数据都不是HTTP报文了而是一个紧接着一个的WebSocket帧。每一帧的头部就这么几个字节0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 -------------------------------------------------------- |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | | | |1|2|3| |K| | | --------------------------------------------------------第一个字节的低四位是opcode高一位是FIN。FIN1表示这条消息结束了FIN0表示后面还有分片。常见的opcode有这几个opcode含义说明0x0延续帧分片消息的后续部分0x1文本帧UTF-8文本如命令控制0x2二进制帧音频数据走这里0x8关闭帧关闭连接握手0x9Ping心跳探测0xAPong对Ping的回应第二个字节的低七位是Payload len但它不是简单的一字节长度如果值小于126就是实际长度如果等于126真正的长度在紧接着的两个字节里无符号16位如果等于127真正的长度在紧接着的八个字节里64位。第二个字节的最高位是MASK位这个下面单独说。解析帧的核心代码就是一个短小的状态判断甚至比很多人想象中简单得多。真正的坑不在头部而在后续的掩码和粘包。2.3 掩码机制浏览器发来的每个字节都要异或RFC 6455规定客户端发给服务器的每一帧MASK位必须为1且必须在头部后面附带4字节的掩码密钥而服务器回给客户端的帧MASK位必须为0。这个不对称经常把第一次手写协议的人搞晕。为什么要这样设计核心目的是防止恶意构造的WebSocket帧被中间设备比如代理、缓存服务器误当成普通HTTP请求的一部分而产生缓存投毒问题。掩码把payload伪装成“看起来不像明文”的数据中间设备就不会错误解析。对服务端来说接收端要做的事情很单纯读4字节掩码密钥然后把payload每个字节和掩码密钥按位置做XOR还原。掩码密钥是4字节循环使用的所以第i个字节的还原逻辑是payload[i] ^ maskKey[i 3]。如果你忘了这步收到的音频文件全是乱码还怎么都排查不出来——这是后来我最想提醒大家的第一大坑。3. 完整实现ServerSocket 手写帧解析的服务端代码下面这个类就是完整服务端我只保留了一个文件复制到任意有JDK的机器上就能编译运行。import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.EOFException; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class ZeroDepWebSocketAudioServer { private static final String WS_GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; private static final String AUDIO_DIR received; private static final AtomicInteger CLIENT_ID new AtomicInteger(); public static void main(String[] args) throws IOException { int port args.length 0 ? Integer.parseInt(args[0]) : 8080; File dir new File(AUDIO_DIR); if (!dir.exists()) { dir.mkdirs(); } ExecutorService pool Executors.newCachedThreadPool(); ServerSocket server new ServerSocket(port); System.out.println(zero-dep websocket audio server, listening on port); while (true) { Socket socket server.accept(); pool.submit(() - handleClient(socket)); } } private static void handleClient(Socket socket) { int clientId CLIENT_ID.incrementAndGet(); FileOutputStream fileOut null; String fileName null; long receivedBytes 0; System.out.println([client- clientId ] connected from socket.getRemoteSocketAddress()); try { socket.setSoTimeout(300_000); DataInputStream in new DataInputStream(new BufferedInputStream(socket.getInputStream())); DataOutputStream out new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); // 1. HTTP upgrade handshake String headers readHttpHeaders(in); System.out.println([client- clientId ] handshake request: \n headers); String upgrade getHeader(headers, Upgrade); String connection getHeader(headers, Connection); String wsKey getHeader(headers, Sec-WebSocket-Key); String version getHeader(headers, Sec-WebSocket-Version); if (!websocket.equalsIgnoreCase(upgrade) || connection null || !connection.toLowerCase().contains(upgrade) || wsKey null || !13.equals(version)) { out.write(HTTP/1.1 400 Bad Request\r\n\r\n.getBytes(StandardCharsets.ISO_8859_1)); out.flush(); return; } String acceptKey computeAcceptKey(wsKey); String response HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Accept: acceptKey \r\n\r\n; out.write(response.getBytes(StandardCharsets.ISO_8859_1)); out.flush(); System.out.println([client- clientId ] handshake ok, waiting for audio frames...); // 2. websocket frame loop boolean running true; while (running) { int b0 in.readUnsignedByte(); int b1 in.readUnsignedByte(); boolean fin (b0 0x80) ! 0; int opcode b0 0x0F; boolean masked (b1 0x80) ! 0; long payloadLen b1 0x7F; if (payloadLen 126) { payloadLen in.readUnsignedShort(); } else if (payloadLen 127) { payloadLen in.readLong(); if (payloadLen 0) { throw new IOException(invalid 64-bit payload length); } } // control frames must not be fragmented and payload length 125 if (opcode 0x8 (!fin || payloadLen 125)) { throw new IOException(invalid control frame); } // per RFC 6455, client to server frames must be masked if (!masked) { throw new IOException(client frame must be masked); } byte[] maskKey new byte[4]; in.readFully(maskKey); switch (opcode) { case 0x8: { // close readAndDiscard(in, payloadLen, maskKey); byte[] closeReply {(byte) 0x88, 0x00}; out.write(closeReply); out.flush(); running false; break; } case 0x9: { // ping - pong byte[] payload new byte[(int) payloadLen]; in.readFully(payload); unmaskInPlace(payload, maskKey); byte[] pong new byte[2 payload.length]; pong[0] (byte) 0x8A; pong[1] (byte) payload.length; System.arraycopy(payload, 0, pong, 2, payload.length); out.write(pong); out.flush(); break; } case 0xA: { // pong from client, ignore readAndDiscard(in, payloadLen, maskKey); break; } case 0x1: { // text frame, we treat it as a command placeholder, ignore payload readAndDiscard(in, payloadLen, maskKey); break; } case 0x2: // binary frame case 0x0: { // continuation frame of a fragmented binary message if (fileName null payloadLen 0) { fileName AUDIO_DIR /audio_ System.currentTimeMillis() _ clientId .bin; fileOut new FileOutputStream(fileName); System.out.println([client- clientId ] start receiving - fileName); } if (fileOut ! null) { writePayloadToFile(in, payloadLen, maskKey, fileOut); receivedBytes payloadLen; if (fin) { fileOut.flush(); System.out.println([client- clientId ] message end, total bytes receivedBytes); } } else { readAndDiscard(in, payloadLen, maskKey); } break; } default: { readAndDiscard(in, payloadLen, maskKey); } } } } catch (SocketTimeoutException e) { System.out.println([client- clientId ] idle timeout, close connection); } catch (EOFException e) { System.out.println([client- clientId ] client closed connection); } catch (Exception e) { System.out.println([client- clientId ] error: e.getMessage()); } finally { try { if (fileOut ! null) fileOut.close(); } catch (IOException ignored) {} try { socket.close(); } catch (IOException ignored) {} System.out.println([client- clientId ] closed, received receivedBytes bytes); } } private static String readHttpHeaders(DataInputStream in) throws IOException { ByteArrayOutputStream buffer new ByteArrayOutputStream(); int state 0; int b; while ((b in.read()) ! -1) { buffer.write(b); if (state 0 b \r) state 1; else if (state 1 b \n) state 2; else if (state 2 b \r) state 3; else if (state 3 b \n) return buffer.toString(ISO-8859-1); else state 0; } throw new IOException(incomplete HTTP headers); } private static String getHeader(String headers, String name) { String prefix name :; for (String line : headers.split(\r\n)) { if (line.regionMatches(true, 0, prefix, 0, prefix.length())) { return line.substring(prefix.length()).trim(); } } return null; } private static String computeAcceptKey(String clientKey) throws NoSuchAlgorithmException { MessageDigest sha1 MessageDigest.getInstance(SHA-1); byte[] digest sha1.digest((clientKey WS_GUID).getBytes(StandardCharsets.ISO_8859_1)); return Base64.getEncoder().encodeToString(digest); } private static void writePayloadToFile(DataInputStream in, long payloadLen, byte[] maskKey, FileOutputStream fileOut) throws IOException { byte[] buffer new byte[16 * 1024]; long remaining payloadLen; int maskOffset 0; while (remaining 0) { int toRead (int) Math.min(buffer.length, remaining); in.readFully(buffer, 0, toRead); if (maskKey ! null) { for (int i 0; i toRead; i) { buffer[i] ^ maskKey[(maskOffset) 3]; } } fileOut.write(buffer, 0, toRead); remaining - toRead; } } private static void readAndDiscard(DataInputStream in, long payloadLen, byte[] maskKey) throws IOException { byte[] buffer new byte[8192]; long remaining payloadLen; int maskOffset 0; while (remaining 0) { int toRead (int) Math.min(buffer.length, remaining); in.readFully(buffer, 0, toRead); if (maskKey ! null) { for (int i 0; i toRead; i) { buffer[i] ^ maskKey[(maskOffset) 3]; } } remaining - toRead; } } private static void unmaskInPlace(byte[] data, byte[] maskKey) { for (int i 0; i data.length; i) { data[i] ^ maskKey[i 3]; } } }3.1 先搭骨架线程池加ServerSocketServerSocket监听端口accept()阻塞等待新连接来一个客户端就丢给线程池处理。我用的是Executors.newCachedThreadPool()因为它适合这种“连接数不稳定、每个连接占用时间不确定”的场景空闲线程会被回收连接爆发时又能自动扩线程。如果你的目标是几百个连接这个写法的性能也够用再往上走才需要考虑NIO和Netty那套。每个连接独立处理互不干扰音频文件也是每个客户端各存各的所以我在文件命名里加了clientId和毫秒时间戳确保多个客户端同时推流也不会互相覆盖。3.2 握手处理这一步做对后面才稳readHttpHeaders用了一个简单的四状态机逐字节读取直到遇到\r\n\r\n把整个HTTP头部读成一个字符串。这里有个容易忽略的细节读取头部和读取帧必须用同一个DataInputStream实例因为BufferedInputStream在读取头部的过程中可能已经把后续的帧字节一起缓冲进来了。如果你读完了头部又新建一个输入流那些已经缓冲的帧字节就丢了服务端会一直卡在等待第一个帧上。握手校验我做了四件事Upgrade是不是websocket、Connection包不包含upgrade、Sec-WebSocket-Key存不存在、Sec-WebSocket-Version是不是13。全部通过才返回101。任何一项不满足直接回400这也是符合RFC 6455行为的。3.3 帧循环masked、FIN、opcode逐一处理帧循环的逻辑很直白读两个头部字节按位拆出FIN、opcode、masked和payloadLen需要扩展长度就再读2字节或8字节然后读4字节掩码密钥最后按opcode分发。我额外做了两条协议校验控制帧opcode 0x8不允许分片且payload长度必须小于等于125。如果客户端发了个大Ping直接断连避免把异常帧当正常数据处理。客户端发来的帧必须带掩码。RFC 6455规定客户端必须掩码服务端收到不掩码的帧应该直接关闭连接。这条校验在实际测试里特别有用能帮你快速发现写测试脚本时漏了掩码。对于二进制帧和延续帧我把payload分块读入16KB缓冲区边解掩码边写文件而不是一次性把整个payload读进内存。这个设计在音频文件几十MB时差距很明显一次性new byte[(int) payloadLen]在高负载下很容易把堆撑爆分块读写则无论文件多大内存占用都是恒定的。4. 音频数据怎么安全落盘分帧、聚合与文件头4.1 按连接聚合还是按消息拆分——取决于客户端行为接收音频文件时最核心的设计决策是每个二进制消息单独存一个文件还是把同一个连接的所有二进制消息追加进同一个文件如果客户端是浏览器里的MediaRecorder它的工作方式是每隔固定时间比如1秒通过ondataavailable吐出一块音频数据然后你把这一个Blob直接ws.send()。这个Blob是同一段连续录音的一部分前一块的结尾和后一块的开头在时间上是连续的。如果你每个Blob单独存一个文件最终会得到一堆无法播放的碎片。所以我的选择是同一个WebSocket连接接收到的所有二进制消息全部追加进同一个文件。连接关闭时文件才关闭。反过来说如果你的客户端是要发送一个已经完整的音频文件例如从磁盘读取的test.webm通常它会作为一个消息发送或者被自动分片成若干个帧发送。无论哪种追加到同一个文件最终都能还原出完整文件。所以“按连接聚合”这个策略对这两种场景都成立。4.2 分块读写别一次性吞掉几十MB我特意没有写byte[] payload new byte[(int) payloadLen]这种代码。原因很简单一个100MB的音频文件如果被浏览器打包成一个大帧你就要一次分配100MB堆内存。JDK默认堆一般才256MB或512MB多来几个客户端直接OOM。writePayloadToFile和readAndDiscard都用的分块逻辑循环读16KB/8KB读满一块就解一次掩码然后立刻写入文件或丢弃。整个过程内存占用是恒定的和音频文件总大小无关。这里掩码的maskOffset是跨块连续的因为掩码密钥是每4字节循环一次必须保证跨块的索引连续性不能每块都从0重新算。这个细节我写的时候特意加了个注释标记。4.3 收到的音频到底是什么格式MediaRecorder在Chrome里默认输出的是audio/webm;codecsopus在Firefox里也通常是WebM容器装Opus编码。所以你在服务端落盘的文件本质上就是一个.webm文件只不过我为了通用性先存成了.bin。验证方式很简单file received/audio_*.bin # 大概率显示: WebM / Matroska media fileWebM文件的起始魔数是1A 45 DF A3你可以用xxd确认xxd -l 16 received/audio_*.bin # 如果看到 1a45dfa3 开头就是WebM容器如果是其他客户端发送的WAV/PCM裸流文件开头就是52 49 46 46ASCII的RIFF。我通常的做法是先把文件存成.bin联调确认格式后再批量重命名成对应扩展名。涉及WAV裸流时还有个可选做法是服务端自己补44字节的WAV头——这需要知道采样率、声道数、位深这些参数一般让客户端额外发一个文本帧把参数传过来服务器解析后写入文件头。4.4 可选的结束标记用文本帧做“录完了”信号目前的设计是连接关闭时文件才落盘。但也有更精细的需求客户端录完一段还想在同一连接里继续录下一段怎么办一个简洁的方案是约定一条文本帧opcode 0x1作为结束标记比如内容为EOF服务端收到后把当前文件收尾关闭然后重置fileOut为null等待下一次二进制帧到来时再开新文件。这个扩展代码量不大只需要在case 0x1分支里判断一下内容而已但它会让整个服务的适用场景宽很多。我实际部署时就是加了这样一个标记把“一次连接只收一段音频”升级成了“一次连接收多段独立音频”。5. 浏览器联调录音直推服务器的完整测试流程5.1 最小验证不用麦克风的纯Java发送端在打开浏览器之前我强烈建议先用一个简单的Java发送端做最小验证。它不依赖任何第三方库只发一个确定的音频文件方便你确认服务端行为完全符合预期。import java.io.DataInputStream; import java.io.DataOutputStream; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Base64; public class AudioFileSender { public static void main(String[] args) throws Exception { if (args.length 1) { System.out.println(usage: java AudioFileSender audio-file); return; } byte[] audio Files.readAllBytes(Paths.get(args[0])); String host 127.0.0.1; int port 8080; try (Socket socket new Socket(host, port); DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream())) { // 1. handshake byte[] rawKey new byte[16]; new java.util.Random().nextBytes(rawKey); String key Base64.getEncoder().encodeToString(rawKey); String request GET /audio HTTP/1.1\r\n Host: host : port \r\n Upgrade: websocket\r\n Connection: Upgrade\r\n Sec-WebSocket-Key: key \r\n Sec-WebSocket-Version: 13\r\n\r\n; out.write(request.getBytes(StandardCharsets.US_ASCII)); out.flush(); // read 101 response StringBuilder response new StringBuilder(); int state 0; int b; while ((b in.read()) ! -1) { response.append((char) b); if (state 0 b \r) state 1; else if (state 1 b \n) state 2; else if (state 2 b \r) state 3; else if (state 3 b \n) break; else state 0; } System.out.println(server response: response); if (!response.toString().contains(101)) { System.out.println(handshake failed); return; } // 2. send one binary frame, MUST be masked byte[] mask {0x11, 0x22, 0x33, 0x44}; out.writeByte(0x80 | 0x2); // FIN binary if (audio.length 126) { out.writeByte(0x80 | audio.length); } else if (audio.length 65535) { out.writeByte(0x80 | 126); out.writeShort(audio.length); } else { out.writeByte(0x80 | 127); out.writeLong(audio.length); } out.write(mask); for (int i 0; i audio.length; i) { out.writeByte(audio[i] ^ mask[i 3]); } out.flush(); System.out.println(sent audio.length bytes, file: args[0]); // 3. close handshake out.writeByte(0x88); out.writeByte(0x00); out.flush(); Thread.sleep(500); } } }编译运行后服务端日志里应该能看到握手成功、开始接收、消息结束三条记录然后received目录下出现一个.bin文件。用cmp比较一下原文件和接收文件如果字节一致说明协议解析和掩码处理全部正确。java AudioFileSender test.webm cmp test.webm received/audio_*.bin echo OK这一步能把“协议层”和“录音业务层”彻底分开验证。如果这里都不过后面浏览器录音测试再怎么折腾都没意义。5.2 浏览器直推MediaRecorder一条龙协议层验证通过后就该上真实场景了。新建一个简单的HTML页面用getUserMedia采集麦克风MediaRecorder每隔1秒吐一个Blob直接通过WebSocket发给服务器。script const ws new WebSocket(ws://localhost:8080); ws.onopen () { console.log(websocket connected); navigator.mediaDevices.getUserMedia({ audio: true }).then(stream { const mediaRecorder new MediaRecorder(stream); mediaRecorder.ondataavailable event { if (event.data event.data.size 0) { ws.send(event.data); } }; mediaRecorder.start(1000); setTimeout(() mediaRecorder.stop(), 15000); }); }; ws.onclose () console.log(websocket closed); ws.onerror e console.error(websocket error, e); /script需要注意getUserMedia在非localhost页面里要求HTTPS环境所以如果你只是在本机测试ws://localhost:8080没问题如果要让局域网另一台手机测试就必须先解决HTTPS证书问题否则浏览器会直接拒绝麦克风权限。录音15秒后received目录下应该出现一个十几KB到几百KB不等的.bin文件。5.3 验证落盘文件能不能播放验证结果我一般按这个顺序来看服务端日志确认received bytes在不断增长说明帧在持续到达。file received/audio_*.bin确认容器格式。把.bin重命名成.webm拖进浏览器地址栏播放。能出声说明从协议解析到文件落盘整个链路都没问题。对比发送端测试用浏览器录一段已知内容的音频再用音频播放器检查时长是否和录音时长一致。如果文件无法播放优先怀疑三点掩码没有解对、分片消息没有聚合、多个Blob没有按顺序追加。这三类问题的排查方法我在下一节展开。6. 踩坑实录让服务端翻车的6个隐藏细节6.1 握手之后千万别新建输入流这是我一开始最容易犯的错。读完HTTP头部后随手又写了个BufferedReader去读后续内容结果前几个帧字节早就被BufferedInputStream缓冲走了新流里永远等不到完整帧服务端表现为“握手成功但一直收不到数据”。正确的做法是从头到尾只创建一个DataInputStream它内部包着同一个BufferedInputStream。readHttpHeaders读取时会把多余字节留在缓冲区里之后帧循环用同一个流继续读那些字节才能被正确消费。这个坑的排查思路其实很典型先怀疑“字节是否被读走”再怀疑“流是否共享”。6.2 忘了解掩码文件全是乱码症状最迷惑人的一个坑。服务端代码看起来全部正常收到的文件大小也对但打开一看全是毫无意义的乱码字节。原因只有一个客户端帧带了掩码你读payload后没有按4字节掩码密钥做XOR还原。验证方法很简单WebM文件的魔数是1A 45 DF A3如果你在xxd输出里看到的不是这四个字节而是被异或过的其他值就基本可以断定是掩码问题。比如第一字节变成0x1A ^ 0x11 0x0B这种数值对不上文件自然无法播放。这个坑之所以常见是因为服务端回给客户端的帧不需要掩码你写回包代码时很顺手就不处理了结果读入端的解掩码也一起忘了。要记住这个不对称客户端必须掩码服务端禁止掩码两边不要混为一谈。6.3 readFully还是readTCP粘包半包的本质手写网络协议时最容易犯的错误就是直接用InputStream.read(byte[])去读固定长度的数据。TCP是字节流没有报文边界。一个WebSocket帧的2字节头部可能在一个TCP包里也可能被拆成两个包各来一半一次read调用可能返回1个字节也可能一次返回几十KB。正确的姿势是使用DataInputStream.readFully它会阻塞直到读满指定长度的字节为止。我在代码里所有的固定长度读取——2字节头部、4字节掩码密钥、扩展长度字段、分块payload——全部走的readFully。这一点是手写协议类代码的底线谁在帧解析里用了普通read谁就会在弱网环境里随机翻车。6.4 分片消息FIN0的帧不等于一条完整音频浏览器发送大块音频数据时WebSocket实现可能把一条消息拆成多个帧第一个二进制帧FIN0中间若干个延续帧opcode0x0最后一个延续帧FIN1。如果你只处理opcode0x2把其它帧都丢弃收到的音频就会出现随机性的内容缺失。我的处理是把0x2和0x0合并成一个分支处理所有帧的payload都追加到同一个文件流里直到遇到FIN1才认为“这条消息结束了”做一次flush。注意flush不等于关闭文件因为对于MediaRecorder的连续录音场景下一条二进制消息可能是同一段录音的下一块必须继续追加到同一个文件。6.5 零长度消息与空文件陷阱有个测试同学用脚本循环给服务端发了几条空Blob结果服务端生成了好几个0字节文件。原因是我的旧版本在收到二进制帧时就立即创建文件即使payload长度是0。现在的版本做了防御只有fileName null payloadLen 0才创建文件payload长度为0的帧直接被readAndDiscard消费掉。这个改动看似不起眼但避免了“满目录空文件”的脏数据。真实环境里客户端偶发发几个空块是完全可能的服务端不应该因此制造垃圾文件。6.6 连接管理和线程安全别让死连接占着文件握手成功后客户端如果一直不发数据线程就会一直在readUnsignedByte上阻塞。我给socket.setSoTimeout(300_000)设了5分钟空闲超时超时后直接抛SocketTimeoutException在finally里关掉文件流和socket避免一个挂死的连接永久占用线程和文件句柄。另外多客户端并发推流时文件命名一定不能冲突。我用“毫秒时间戳 全局递增clientId”组合理论上即使同一毫秒也绝对不重名。当然这只是一个小服务的处理方式如果你要支撑大量并发客户端那还是回到一开始说的换Netty并且要引入真正的连接管理器。我在实际部署中还加了一个小功能收到客户端文本帧EOF时主动关闭当前音频文件并重置状态这样不依赖连接断开就能结束一段录音。整套服务从握手到落盘纯JDK一个文件至今在日常录音采集任务里跑得很稳。零依赖手写WebSocket不是什么炫技它只是让你在资源受限时依然能交付一个扎实可用的服务。
阅读完成 · 觉得有帮助?