news 2026/10/19 10:30:47

零依赖手写WebSocket服务端:纯JDK实现接收浏览器音频推送

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
零依赖手写WebSocket服务端:纯JDK实现接收浏览器音频推送

如果只是图省事,我大概率会直接引入Spring的那套东西,让WebSocket服务器接收音频文件变成一个注解的事。但有个现实场景逼着我换了个思路:某内网交付项目,运行环境只有基础JDK,Maven私服和外部网络全都没有,任何第三方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概念多,线程模型复杂高并发、海量长连接
Tyrus(JSR 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: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

这个Sec-WebSocket-Accept的计算公式是固定的:把客户端发来的Sec-WebSocket-Key拼上一个写死的GUID字符串,做SHA-1散列,再Base64编码。GUID就是258EAFA5-E914-47DA-95CA-C5AB0DC85B11,RFC 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。FIN=1表示这条消息结束了,FIN=0表示后面还有分片。常见的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 先搭骨架:线程池加ServerSocket

ServerSocket监听端口,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;codecs=opus,在Firefox里也通常是WebM容器装Opus编码。所以你在服务端落盘的文件,本质上就是一个.webm文件,只不过我为了通用性先存成了.bin。验证方式很简单:

file received/audio_*.bin # 大概率显示: WebM / Matroska media file

WebM文件的起始魔数是1A 45 DF A3,你可以用xxd确认:

xxd -l 16 received/audio_*.bin # 如果看到 1a45dfa3 开头,就是WebM容器

如果是其他客户端发送的WAV/PCM裸流,文件开头就是52 49 46 46(ASCII的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 验证落盘文件能不能播放

验证结果我一般按这个顺序来:

  1. 看服务端日志,确认received bytes在不断增长,说明帧在持续到达。
  2. file received/audio_*.bin,确认容器格式。
  3. 把.bin重命名成.webm,拖进浏览器地址栏播放。能出声,说明从协议解析到文件落盘整个链路都没问题。
  4. 对比发送端测试:用浏览器录一段已知内容的音频,再用音频播放器检查时长是否和录音时长一致。

如果文件无法播放,优先怀疑三点:掩码没有解对、分片消息没有聚合、多个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还是read?TCP粘包半包的本质

手写网络协议时,最容易犯的错误就是直接用InputStream.read(byte[])去读固定长度的数据。TCP是字节流,没有报文边界。一个WebSocket帧的2字节头部可能在一个TCP包里,也可能被拆成两个包各来一半;一次read调用可能返回1个字节,也可能一次返回几十KB。

正确的姿势是使用DataInputStream.readFully,它会阻塞直到读满指定长度的字节为止。我在代码里所有的固定长度读取——2字节头部、4字节掩码密钥、扩展长度字段、分块payload——全部走的readFully。这一点是手写协议类代码的底线,谁在帧解析里用了普通read,谁就会在弱网环境里随机翻车。

6.4 分片消息:FIN=0的帧不等于一条完整音频

浏览器发送大块音频数据时,WebSocket实现可能把一条消息拆成多个帧:第一个二进制帧FIN=0,中间若干个延续帧opcode=0x0,最后一个延续帧FIN=1。如果你只处理opcode=0x2,把其它帧都丢弃,收到的音频就会出现随机性的内容缺失。

我的处理是把0x2和0x0合并成一个分支处理:所有帧的payload都追加到同一个文件流里,直到遇到FIN=1才认为“这条消息结束了”,做一次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不是什么炫技,它只是让你在资源受限时,依然能交付一个扎实可用的服务。

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

三周开发 json-formatter 插件:在 WorkBuddy 攒 Credits 的实战复盘

标题起得有点中二&#xff0c;但事情是真实的&#xff1a;过去三周&#xff0c;我在 WorkBuddy 这个开发者工具平台上开发并上架了一个叫 json-formatter 的小插件&#xff0c;靠它攒下了一笔 Credits 点数。WorkBuddy 是一个面向开发者和效率党的第三方工具插件市场&#xff0…

作者头像 李华
网站建设 2026/10/19 10:29:17

多表查询性能优化实战:JOIN、子查询与索引设计详解

1. 多表查询的完整认知 要说数据库开发里哪个坎儿绕不过去&#xff0c;多表查询绝对排得上号。刚入行那会儿我也天真地以为能写个单表SELECT就够用了&#xff0c;直到第一次面对十几张表关联的业务需求&#xff0c;整个人都是懵的。后来在项目里反复折腾、排查、优化&#xff0…

作者头像 李华
网站建设 2026/10/19 10:28:42

从自然图像到医学CT:小波变换与U-Net的超分重建落地实战

简介&#xff1a;面向深度学习、图像识别与医学图像处理方向的学习者、研究者和工程师&#xff0c;该资源是基于深度学习的图像超分辨率重建项目实践包&#xff0c;聚焦自然图像与医学影像两大场景&#xff0c;可支撑低分辨率医学图像重建及可视化相关研究。自然图像部分在DIV2…

作者头像 李华
网站建设 2026/10/19 10:28:41

EasyNVR 3DES弱加密漏洞修复实战:TLS配置整改与验证全流程

安全扫描报告里出现“EasyNVR检测到3DES弱加密算法漏洞”这条高危项时&#xff0c;很多刚接手视频运维项目的同行会先愣一下&#xff1a;平台明明跑得好好的&#xff0c;十几路RTSP摄像头预览、回放都正常&#xff0c;怎么突然冒出个密码学漏洞。我当时处理第一个整改单时也是这…

作者头像 李华
网站建设 2026/10/19 10:28:06

MySQL底层原理与实战:索引、事务、日志与锁全解析

MySQL 这名字&#xff0c;干开发的基本都绕不开。我当年啃这套数据库知识的时候&#xff0c;最烦的就是知识点太散&#xff1a;索引讲一遍&#xff0c;事务讲一遍&#xff0c;锁又讲一遍&#xff0c;真到排查问题的时候脑子里全是浆糊。后来我干脆跟着一套以开发应用为导向的专…

作者头像 李华
网站建设 2026/10/19 10:28:03

PCA人脸识别与特征脸方法:核心原理、代码实现与避坑指南

上周帮一个课程项目调试人脸识别Demo&#xff0c;卡了一个晚上&#xff0c;最后发现瓶颈不在模型多复杂&#xff0c;而在PCA这一步没有真正吃透。基于PCA的人脸识别&#xff0c;也就是常说的特征脸方法&#xff0c;是很多人接触模式识别时第一个完整跑通的算法&#xff1a;把一…

作者头像 李华