WebRTC 局域网文件直传实战:信令、数据通道与移动端踩坑
很多文件传输需求看起来很简单:电脑选一个文件,手机扫码确认,然后直接收下。
真正开始实现后会发现,文件要不要经过服务器、两个设备如何找到彼此、二维码里该放什么、如何避免别人抢先加入、手机浏览器为什么突然报错,都是必须提前想清楚的问题。
本文以“同一 Wi-Fi 或手机热点下,浏览器之间传文件”的业务场景为例,完整拆解一个可靠的实现方案。目标很明确:服务端只负责建房、鉴权和信令转发,文件字节始终在两个浏览器之间直连传输。
1. 业务背景:为什么不走普通上传下载
常见做法是:发送端上传文件到对象存储,接收端再下载。
这个方案适合跨网、离线下载和长期保存,但对于“桌面给手机传一张图片”这类临时场景,代价偏高:
- 文件会经过服务器,带来带宽和存储成本。
- 用户需要等待“上传完成”再等待“下载完成”。
- 文件名、大小、内容会进入服务端链路,隐私边界更复杂。
- 大文件会放大网关、对象存储和清理策略的压力。
局域网直传适合另一类需求:设备在线、距离近、需要一次性快速交付,并且不要求服务端留存文件。
WebRTC DataChannel 正好满足这个边界:浏览器之间建立加密的数据通道,服务端仅帮助双方完成“认识彼此”和“协商连接”。
2. 先分清两条链路:信令不是文件传输
实现这类功能最容易产生的误解是:既然页面里有 WebSocket,那么文件是不是通过 WebSocket 发走了?
不是。
两条链路的职责完全不同:
| 链路 | 承载数据 | 是否经过服务端 |
|---|---|---|
| HTTP | 建房、加入、隐私同意、临时令牌 | 是 |
| WebSocket 信令 | SDP、ICE candidate、确认/取消等小消息 | 是 |
| WebRTC DataChannel | 文件清单、文件哈希、二进制分片 | 否 |
因此,抓 WebSocket Frames 时看不到文件内容是正常现象。真正的传输量应在浏览器的 WebRTC 调试信息中查看,例如 Chrome 的 chrome://webrtc-internals。
3. 完整时序:从扫码到收到文件
这里有三个值得注意的阶段。
3.1 ready:避免协商竞态
发送端建房后不应立刻生成 offer。如果接收端尚未连接信令服务,offer 没有目标,后续还要额外处理重试和重协商。
更稳妥的办法是接收端连上 WebSocket 后发送 ready,发送端收到后才创建 offer。ready 还可以携带一个仅用于展示的设备名,让发送端显示“某设备已加入,正在建立直连”。
3.2 offer 和 answer:协商数据通道能力
offer 和 answer 里承载的是 SDP。它描述的是双方如何建立 WebRTC 会话,例如:
- DTLS 指纹,用于加密握手和身份校验。
- ICE 的临时用户名和密码。
- SCTP 端口和 DataChannel 能力。
- 是否支持有序、可靠的数据通道。
SDP 不是文件,也不是最终要使用的对端地址。
3.3 ice_candidate:让浏览器找到真正可通的路径
每个浏览器会从 Wi-Fi、以太网、VPN、虚拟网卡和 IPv6 接口收集候选地址。应用代码无需解析候选字符串,只需通过信令服务原样转给对端。
例如:
text
candidate:... 1 udp ... 192.168.1.24 50406 typ host ...
candidate:... 1 udp ... a1b2c3d4.local 52997 typ host ...
typ host表示本机网卡候选。192.168.x.x是局域网私网地址和临时 UDP 端口。*.local通常是浏览器用 mDNS 隐藏真实局域网 IP 的主机名。
对端浏览器把候选交给自己的 ICE 引擎后,会自动做候选配对和 UDP 连通性探测,最终选出一条可用路径。一个设备暴露多个候选不代表都会使用,最后只会选择实际能互通的 candidate pair。
4. 文件协议:如何在 DataChannel 中传一个文件
DataChannel 可以发送字符串和二进制数据。一个简单、足够实用的文件协议可以是:
text
manifest
文件名、大小、MIME 类型
file_start
文件名、大小、MIME 类型、SHA-256
binary chunk
文件 ArrayBuffer 分片
file_end
当前文件结束
transfer_complete
本批文件结束
发送端的核心逻辑如下:
js
const hash = await sha256(file)
channel.send(JSON.stringify({
type: 'file_start',
name: file.name,
size: file.size,
mime: file.type,
hash
}))
for (let offset = 0; offset < file.size; offset += 64 * 1024) {
await waitForLowBuffer(channel)
const chunk = await file.slice(offset, offset + 64 * 1024).arrayBuffer()
channel.send(chunk)
}
channel.send(JSON.stringify({ type: 'file_end', name: file.name }))
waitForLowBuffer 很关键。DataChannel 有发送缓冲区,直接循环塞入大文件会使内存迅速膨胀。应当观察 bufferedAmount,超过阈值后等待 bufferedamountlow 事件,再继续发送。
接收端按顺序暂存分片,在 file_end 后合并为 Blob,重新计算 SHA-256 并与 file_start 提供的值比较。校验成功后才允许用户下载。
小文件的进度条可能从 0% 直接跳到 100%,这通常不是“假传输”:如果分片大小是 64 KiB,而图片只有二十多 KiB,整个文件只会产生一个二进制分片,进度自然只更新一次。
5. 房间与权限:二维码不能只放一个六位码
六位房间码适合人工输入,但不适合作为唯一凭证,原因很简单:可枚举空间太小。
一个更合理的房间设计是:
| 字段 | 作用 |
|---|---|
roomId |
内部唯一房间标识 |
displayCode |
六位展示码,方便人工输入 |
roomSecret |
高随机密钥,二维码和分享链接中携带 |
role |
sender 或 receiver |
expiresAt |
短期有效时间,例如 15 分钟 |
| 临时 JWT | WebSocket 握手凭证 |
服务端只保存 roomSecret 的哈希值,而不是明文。接收端提交展示码和密钥,通过验证后获取 receiver 角色的短期 JWT。
信令服务在 WebSocket 握手时校验:
- JWT 签名是否正确。
- 发行方、过期时间、房间 ID、角色是否一致。
- Redis 中的短期房间是否仍然存在。
校验成功后,将 roomId 和 role 绑定到当前 WebSocket 连接。之后收到的信令不应相信客户端指定的接收者,而应只转发给同房间、另一角色的连接。
6. 多设备加入:先把产品规则说清楚
“多个设备加入房间”并不是单纯放开校验那么简单。
第一期建议明确采用 1 对 1 房间:一个发送端、一个接收端。这样一个 DataChannel 对应一条文件传输,确认、取消、哈希校验和进度都非常清晰。
加入接收端时必须是原子操作。若两个手机同时扫码,普通的“先查无人加入,再写接收者”会产生竞态:两个请求可能都认为房间空闲,最后后写入的人覆盖先写入的人。
可以用 Redis WATCH 加事务解决:
一发多收是另一个产品模型:房间需要维护接收设备集合,每台设备要有独立令牌和独立状态;发送端要为每个接收端建立 DataChannel,确认、进度、失败和完成回执也都要逐设备维护。没有这些改造时,不应把 1 对 1 房间简单改成“允许多人加入”。
7. 两个真实踩坑
7.1 手机 HTTP 页面没有 crypto.subtle
本地调试时,电脑通常通过 http://localhost 打开页面,浏览器把 localhost 视为安全上下文。手机扫码后访问的是 http://192.168.x.x:端口,这不是安全上下文。
iOS Safari 在这种情况下可能没有 crypto.subtle。如果文件校验代码直接执行:
js
crypto.subtle.digest('SHA-256', buffer)
就会出现:
text
Cannot read properties of undefined (reading 'digest')
正确做法是优先使用 Web Crypto,并提供浏览器兼容的纯 JavaScript SHA-256 回退;生产环境则应使用 HTTPS,让原生 Web Crypto 正常可用。
7.2 容器网络里 127.0.0.1 不等于宿主机
本地开发时,前端在宿主机运行,网关和信令服务可能在 Docker 中。此时容器内的 127.0.0.1 指向容器自身,不一定是另一个服务。
建议把所有容器服务接入同一个 bridge 网络,通过服务名访问:
text
网关 -> ws://signaling-service:18111
业务服务 -> redis:6379
业务服务 -> mysql:3306
业务服务 -> nacos:8848
宿主机直接启动的开发服务仍使用端口映射后的 localhost。不要把容器内地址和宿主机地址混在同一套配置里。
8. 上线前检查清单
- 文件字节没有进入 HTTP 上传接口、网关日志、消息队列、对象存储或数据库。
- WebSocket 只接受白名单信令类型,并限制单帧大小和频率。
- 房间码配合高随机密钥,密钥不落库明文。
- 临时 JWT 绑定房间和角色,并在房间过期后立即失效。
- 接收端确认文件清单后才允许发送二进制分片。
- 发送端和接收端都处理取消、断连、房间过期和校验失败。
- 接收端校验 SHA-256 成功后再展示下载入口。
- 日志不记录完整密钥、JWT、SDP、ICE candidate 和文件名。
- HTTPS/WSS 已启用;移动端的 Web Crypto 能力经过真机验证。
- 当前是否支持 1 对 1 或一发多收,已在产品界面和后端规则中明确说明。
9. 可落地的配置与关键代码
前面的流程如果只停留在概念层,排查时仍然会无从下手。本节给出一组可直接映射到任意 Vue、Go、Java 或 Node 服务的配置和伪代码。名称均为示例,实际项目可按自己的网关、服务发现和配置中心替换。
9.1 前端按环境选择信令入口
开发机上的浏览器访问 localhost 时,Vite 可以把 /api 代理到网关;但手机实际打开的是 http://局域网IP:Vite端口。为了避免手机的信令连接错误地落到 Vite 开发服务器,开发环境最好直接指定局域网可访问的网关地址。
dotenv
# .env.development
# 电脑的局域网 IP 变动后替换这里;端口是网关对宿主机暴露的端口。
VITE_LAN_FILE_WS_URL=ws://192.168.10.177:18080/api/im/ws
# .env.production
# HTTPS 页面必须配 wss,浏览器会阻止 HTTPS 页面连接明文 ws。
VITE_LAN_FILE_WS_URL=wss://ws.example.com/api/im/ws
Vite 的 VITE_* 变量在构建时注入前端包。因此改动生产变量后必须重新构建并发布静态资源,单独重启后端不会让旧页面获得新地址。
js
// real-adapter.js
function getSignalUrl() {
// 1. 环境变量存在时优先用它:生产独立 WS 域名、真机局域网调试都走这里。
if (import.meta.env.VITE_LAN_FILE_WS_URL) {
return import.meta.env.VITE_LAN_FILE_WS_URL
}
// 2. 未配置时才与页面同源:开发服务器的 /api WebSocket 代理可接住该连接。
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${scheme}//${window.location.host}/api/im/ws`
}
开发服务器需要显式开启 WebSocket Upgrade 透传,HTTP 代理配置通常如下:
js
// vite.config.js(示意)
server: {
host: '0.0.0.0', // 允许手机通过电脑局域网 IP 打开 Vite
proxy: {
'/api': {
target: 'http://localhost:18080',
changeOrigin: true,
ws: true // 没有它,/api/im/ws 不会从 HTTP 升级为 WebSocket
}
}
}
9.2 浏览器:WebSocket 只交换协商消息
下面的客户端封装有两个安全边界:握手携带短期 token、roomId 和 role;发送阶段只允许白名单信令类型。它从不调用 socket.send(fileBuffer)。
js
const SIGNAL_TYPES = ['ready', 'offer', 'answer', 'ice_candidate', 'accept', 'reject', 'cancel']
class SignalingClient {
async connect({ url, token, roomId, role }) {
// URL query 供服务端在 Upgrade 握手阶段鉴权,并将身份绑定到该连接。
this.socket = new WebSocket(
`${url}?token=${encodeURIComponent(token)}` +
`&roomId=${encodeURIComponent(roomId)}` +
`&lanFileRole=${encodeURIComponent(role)}`
)
await once(this.socket, 'open')
}
send(signalType, payload = {}) {
if (!SIGNAL_TYPES.includes(signalType)) throw new Error('unsupported signal')
if (this.socket.readyState !== WebSocket.OPEN) throw new Error('signaling not connected')
// 这是几百字节到数 KB 的 JSON,而不是文件数据。
this.socket.send(JSON.stringify({
type: 'lan_file_signal',
ext: { scene: 'lan_file_transfer', signalType, payload }
}))
}
}
WebRTC 包装层同样不理解文件内容。它只负责把浏览器产生的 candidate 通知上层,以及在远端描述尚未设置时缓存 candidate,避免 addIceCandidate 时序错误。
js
const peer = new RTCPeerConnection({ iceServers: [] })
const pendingCandidates = []
peer.onicecandidate = ({ candidate }) => {
// candidate 可能是 IPv4、IPv6 或 mDNS .local 名称;应用原样转发,不能自行拆字符串解析。
if (candidate) signaling.send('ice_candidate', { candidate })
}
async function addRemoteCandidate(candidate) {
if (!peer.remoteDescription) {
pendingCandidates.push(candidate) // answer/offer 到达后再注入
return
}
await peer.addIceCandidate(candidate)
}
当前示例的 iceServers 为空,适合同一局域网或本机联调。若要保证跨 NAT、跨公网可用,必须部署并配置 STUN/TURN;信令服务器本身不能代替 TURN 中继。
9.3 浏览器:DataChannel 文件协议与背压
发送端先在 DataChannel 发送文件清单,接收端确认后再发送字节。这样接收者可以在真正传输前看到文件名和大小。二进制分片没有文件名,因此协议约定它属于最近一个尚未结束的 file_start。
js
async function sendFile(channel, file) {
const hash = await sha256(file)
channel.send(JSON.stringify({
type: 'file_start',
name: file.name,
size: file.size,
mime: file.type,
hash // 接收端合并后必须重新计算,防止传输损坏
}))
const chunkSize = 64 * 1024
for (let offset = 0; offset < file.size; offset += chunkSize) {
// 防止大文件把 DataChannel 缓冲和浏览器内存塞满。
while (channel.bufferedAmount > channel.bufferedAmountLowThreshold) {
await once(channel, 'bufferedamountlow')
}
channel.send(await file.slice(offset, offset + chunkSize).arrayBuffer())
}
channel.send(JSON.stringify({ type: 'file_end', name: file.name }))
}
async function sha256(blob) {
const bytes = await blob.arrayBuffer()
// HTTPS 下优先使用原生 Web Crypto;局域网 HTTP 真机调试需提供 JS 回退实现。
if (globalThis.crypto?.subtle) {
return toHex(await crypto.subtle.digest('SHA-256', bytes))
}
return jsSha256(bytes)
}
接收端的核心顺序是:file_start 创建暂存对象,二进制帧持续追加,file_end 合并成 Blob,算 hash 后才创建可下载的 File。不要在收到每个分片时直接触发下载,也不要信任发送端声明的完成状态。
9.4 房间服务:短期凭证、密钥哈希和 1 对 1 原子加入
建房接口返回给发送端 roomId、展示码、二维码用的高随机密钥以及 sender token。Redis 只存密钥哈希和短期房间状态,避免明文密钥泄露到缓存导出或日志。
go
func CreateRoom(deviceName string) (RoomResponse, error) {
roomID := randomHex(16)
roomSecret := randomHex(16) // 二维码中携带,不写入 Redis 明文
room := Room{
RoomID: roomID,
DisplayCode: uniqueSixDigitCode(),
RoomSecretHash: sha256Hex(roomSecret),
SenderDevice: safeDeviceName(deviceName),
ExpiresAt: now().Add(15 * time.Minute),
}
redisSetWithTTL("lan:file:room:"+roomID, json(room), 15*time.Minute)
redisSetWithTTL("lan:file:code:"+room.DisplayCode, roomID, 15*time.Minute)
return RoomResponse{
RoomID: roomID, RoomSecret: roomSecret, Role: "sender",
Token: signJWT(roomID, "sender", room.ExpiresAt),
}, nil
}
加入接口必须避免“先读后写”的并发漏洞。下面的乐观事务使两个手机同时扫码时只有一个成功占位;另一个重试后会看到房间已满。
go
func JoinRoom(code, secret, device string) (RoomResponse, error) {
roomID := redisGet("lan:file:code:" + code)
key := "lan:file:room:" + roomID
err := redisWatch(key, func(tx Tx) error {
room := tx.GetJSON(key)
if room.ReceiverDevice != "" { return ErrRoomFull }
if sha256Hex(secret) != room.RoomSecretHash { return ErrBadSecret }
room.ReceiverDevice = safeDeviceName(device)
// 检查和写入在同一 WATCH/MULTI/EXEC 事务中完成。
return tx.SetJSONWithRemainingTTL(key, room)
})
if err != nil { return RoomResponse{}, err }
return RoomResponse{RoomID: roomID, Role: "receiver", Token: signJWT(roomID, "receiver", room.ExpiresAt)}, nil
}
JWT 至少绑定 roomId、role、iss、iat、exp 和随机连接标识。它是“允许哪条 WebSocket 进入哪个房间”的短期凭证,不是文件下载链接,也不应给客户端额外的服务端数据权限。
9.5 信令服务:握手绑定身份,按房间转发
网关的职责是将外部 /api/im/ws Upgrade 到信令服务内部端口,路径重写必须保留 query 参数:
java
// Gateway 路由伪代码
route("im-ws", requestPath("/api/im/ws"))
.rewritePath("/api/im/ws", "/im/ws")
.uri("ws://im-signaling-service:18111");
WebSocket 握手成功前,服务端验证 token 与请求参数匹配,并把可信身份写入连接上下文。后续的每一帧只读取这个上下文,不能相信 JSON 里伪造的房间或收件人字段。
java
void onHandshake(Request request, Channel channel) {
String token = request.query("token");
String roomId = request.query("roomId");
String role = request.query("lanFileRole");
// 校验签名、签发者、过期时间、roomId/role 声明,以及 Redis 房间是否还存在。
Claims claims = verifyLanFileToken(token, roomId, role);
channel.attr(ROOM_ID).set(claims.roomId());
channel.attr(ROLE).set(claims.role());
channel.attr(CONNECTION_ID).set(randomConnectionId());
continueWebSocketUpgrade(request);
}
void onTextFrame(Channel sender, Packet packet) {
require(packet.scene().equals("lan_file_transfer"));
require(ALLOWED_TYPES.contains(packet.signalType()));
require(serializedSize(packet) <= 64 * 1024);
// roomId/role 只从握手绑定属性获取。只投递给同一房间且角色相反的连接。
for (Channel peer : sessions.byRoom(sender.attr(ROOM_ID))) {
if (!peer.id().equals(sender.id()) && !peer.attr(ROLE).equals(sender.attr(ROLE))) {
peer.writeAndFlush(rebuildPacket(packet, sender.attr(ROOM_ID)));
}
}
}
这种设计带来三个结果:信令服务不保存文件、不需要理解 SDP/ICE 的内容,也不会因客户端伪造 roomId 把消息投递到别的房间。横向扩容时还需要让同房间会话可跨节点发现并转发,例如用 Redis Pub/Sub、消息队列或一致性路由;仅维护单机内存会话表只能支持双方连到同一个信令实例。
10. 排障顺序:先断定哪条链路失败
不要把“文件不能传”笼统归为 WebRTC 问题。按下面顺序能更快定位:
- HTTP 建房和加入是否返回各自角色的短期 token。
- 两端 WebSocket 是否都是
101 Switching Protocols,服务端是否记录同一roomId的 sender、receiver 会话。 - 接收端
ready是否被发送端收到;没有则检查网关 Upgrade、路径重写和信令路由。 - offer、answer、candidate 是否在两端 WebSocket Frames 中成对出现;没有则检查角色和时序。
chrome://webrtc-internals是否出现成功的 candidate pair 与 opened DataChannel;没有则检查局域网隔离、防火墙、VPN 和 mDNS。- DataChannel 打开后是否能收到 manifest;能收到但文件失败时,检查分片顺序、
bufferedAmount背压、SHA-256 和手机 HTTP 下的crypto.subtle回退。
将 HTTP、信令和文件数据通道分别观察,才能既保留“文件不落服务端”的收益,也让问题有可验证的定位路径。
结语
WebRTC 文件直传并不等于“没有后端”。后端仍然承担房间生命周期、身份校验、信令转发、限流和审计边界;只是它不再搬运文件字节。
把信令与数据通道明确分层,把房间权限做成短期、可验证、原子占位的状态机,再针对移动端安全上下文和容器网络进行验证,才能让“扫码传文件”从一个演示功能变成可以稳定交付的真实能力。