简介:面向P2P网络通信学习者与C++开发者的实操示例包,聚焦点对点连接、NAT穿透和P2P服务搭建。内部包含可运行的P2PClientTest程序及配套工程,演示服务发现、设备上线、穿透访问与数据交换等环节,并将IP、端口、密钥配置及P2P服务器协调逻辑落到代码层面;工程中还展示了已连接P2P服务器(在线)设备的状态判断与消息收发流程,便于逐段跟踪。资源共24个文件,由C++源文件、头文件、静态库、DLL、可执行程序及readme说明文档组成,附带VS工程配置,可直接编译调试,并可借助工程目录快速定位相关模块。压缩包仅2.09MB,轻量完整,已有446人学习下载。通过实际运行与改写测试,可以掌握P2P基础架构、UDP打洞/STUN等穿透思路,积累网络编程和调试经验,为后续实现文件共享、流媒体传输等应用打下基础。

1. p2pDemo 是什么:两个端点直连,为什么要绕开服务器

两个终端各自身处不同网络,要把一个 1GB 的文件从 A 送到 B。传统做法是经服务器中转,先上传再下载,服务器带宽成了瓶颈,还可能产生资源费用。p2pDemo 这类示例项目要解决的就是这件事:让两个端点先通过一个轻量信令服务交换连接信息,握手完成后直接点对点传输,服务器只承担介绍人的角色,不碰业务数据。它把 P2P 链路里最难协调的三段串了起来:信令交换、NAT 穿透、DataChannel 分片传输。适合想在一个最小可运行的例子上快速验证 P2P 方案、再叠加自己业务逻辑的开发者和网络方向的学生。

2. P2P 通信的基本盘:节点发现、NAT 穿透与 ICE 协商

2.1 为什么纯 P2P 需要“信令”这层中间人

P2P 直连不代表没有中心节点。两个端点要互相找到对方,总得有一个人先知道对方在哪里、用什么协议打招呼。常见做法是部署一个轻量信令服务,只负责两件事:把愿意互传的端点配对,转发建立连接所需的握手消息。这个服务不碰业务数据,所以带宽压力比传统中转小一两个数量级。我一般用 WebSocket 做这条信令通道,双向、低延迟,浏览器原生支持,Node.js 里也有成熟的 ws 库,几十行就能跑起来。

信令服务的存在常常被误解成“那还不是要服务器”。区别在于:握手完成后,就算信令服务立刻宕机,已经建立的 P2P 连接通常还能继续传数据。这就是 P2P 的核心收益——中间人不参与业务流的搬运。

2.2 四种 NAT 类型与 UDP 打洞的边界:先摸清自己的网络

绝大多数终端在局域网里,没有公网 IP,外界无法主动连接它。NAT 就是那堵墙,墙的脾气决定了 P2P 能不能打通。按映射行为分四种:全锥形 NAT 对任何外部地址都用同一个映射,最好打通;受限锥形只限制外部 IP;端口受限锥形同时限制 IP 和端口;对称型 NAT 对每个目标地址和端口都建立独立映射,最麻烦。UDP 打洞的基本思路是:A 往 B 的公网地址发一个包,B 也往 A 的公网地址发一个包,双方各自在 NAT 上留下映射表项,后续包就能顺着这些表项互达。对称型 NAT 打洞成功率低,因为 A 发往 B 的映射端口和 B 回包指向的端口对不上。

想快速摸清自己网络的 NAT 行为,不必专门装工具。在浏览器 DevTools 控制台里执行下面这段,观察 ICE candidate 的 typ 字段:

// 在浏览器 DevTools Console 里执行,观察本机网卡的 NAT 映射行为
const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});
pc.createDataChannel("probe");
pc.onicecandidate = (e) => {
  if (e.candidate) {
    // typ 字段:host = 本机局域网地址,srflx = NAT 映射后的公网地址
    console.log(e.candidate.candidate);
  }
};
pc.createOffer().then((offer) => pc.setLocalDescription(offer));

刷新几次,观察输出的 candidate 行。typ host 说明拿到的还是内网地址;typ srflx 出现说明 STUN 已经问到了公网映射。如果两个端点都是 host,通常意味着双方在同一局域网;如果一方 srflx 始终不出现,多半是 STUN 请求被防火墙拦了,或者网络环境不允许 UDP 出网。遇到这种情况先排查基础连通性,再谈打洞。

2.3 ICE 协商流程:host、srflx、relay 三条路径怎么选

ICE 是 WebRTC 用来选择连通路径的机制。每一端先收集 candidate,然后通过信令交换。candidate 有三种来源:host 是本机网卡上的地址,srflx 是 STUN 反射得到的 NAT 公网映射,relay 是 TURN 中继服务器分配的转发地址。ICE 的连通性检查会尝试所有 candidate 对的组合,选一条实测可达的路径,优先级基本是 host 优于 srflx、srflx 优于 relay。

理解这个顺序对排错很有用。如果最终选中的是 relay 路径,数据会先到 TURN 中继服务器再转发给对方,虽然也能通,但速度受限于中继带宽,这时候“P2P”实际上是降级成了“服务器中转”。这也是为什么生产环境的 WebRTC 方案里,TURN 往往只在打洞失败时兜底,而不是一上来就用。看到 relay candidate 被选中,先别急着优化分片,优先检查 NAT 类型和 UDP 限速。

3. 最小信令服务:用 Node.js + WebSocket 搭一个房间配对器

3.1 信令服务只做三件事:配对、转发 SDP、转发 candidate

信令服务的功能边界必须刻在脑子里:不做鉴权、不存文件、不转发业务数据。配对就是把两个客户端放进同一个房间;转发 SDP 是搬运 offer 和 answer;转发 candidate 是搬运 ICE 候选地址。除此之外什么都不用做。一旦信令服务参与业务数据搬运,带宽和延迟问题就会回来,P2P 就名存实亡了。

为什么要用房间的概念?因为现实中的使用场景不是只有两个人。房间把一个大型网络拆成小群体,每个房间里通常只有两个活跃端点。服务器只需要维护一张 roomId 到 WebSocket 连接集合的映射表,消息进来后按房间号找到对应集合,再转发给集合里的其他人。这个模型简单到不容易出错,也方便以后扩展出“房间内多人聊天”之类的玩法。

3.2 完整代码:带房间管理的 WebSocket 信令服务器

下面这个文件就是整个信令服务,保存为 server.js 后 npm install ws && node server.js 即可运行:

// server.js:最小 WebSocket 信令服务器
// 运行方式:npm install ws && node server.js
// 依赖:Node.js 18+
const { WebSocketServer } = require("ws");

const wss = new WebSocketServer({ port: 3001 });
const rooms = new Map(); // roomId -> Set<WebSocket>

function send(ws, payload) {
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload));
}

function relay(from, payload) {
  const peers = rooms.get(from.roomId);
  if (!peers) return;
  // 只发给同房间内除自己以外的连接
  for (const peer of peers) {
    if (peer !== from) send(peer, { ...payload, from });
  }
}

wss.on("connection", (ws) => {
  ws.on("message", (raw) => {
    let msg;
    try {
      msg = JSON.parse(raw);
    } catch {
      return; // 非 JSON 直接丢弃
    }

    if (msg.type === "join") {
      const roomId = String(msg.roomId || "default");
      ws.roomId = roomId;
      if (!rooms.has(roomId)) rooms.set(roomId, new Set());
      rooms.get(roomId).add(ws);
      relay(ws, { type: "peer-joined" });
    } else if (
      msg.type === "offer" ||
      msg.type === "answer" ||
      msg.type === "candidate" ||
      msg.type === "leave"
    ) {
      relay(ws, { type: msg.type, data: msg.data });
    }
  });

  ws.on("close", () => {
    if (ws.roomId && rooms.has(ws.roomId)) {
      rooms.get(ws.roomId).delete(ws);
    }
  });
});

console.log("信令服务已启动:ws://localhost:3001");

这段代码把“转发”和“业务解析”彻底解耦。relay 函数只认 type 和 data 两个字段,SDP、candidate 具体是什么结构它一概不关心,所以后续想加自定义消息类型,只需要在消息类型白名单里加一个字符串,不用改转发逻辑。join 处理里先设置 ws.roomId 再写入房间集合,顺序很重要,relay 依赖 from.roomId 才能找到房间,顺序反了会把消息丢给空的房间集合。

端口选 3001 是有意避开 3000 和 8080。开发机上同时跑前端 dev server 和信令服务很常见,3001 冲突概率低。生产部署时这个端口要改成可配置项,通过环境变量注入,写在代码里只是为了让 demo 开箱即用。

3.3 消息协议设计与 4 个参数选择

信令协议只需要五种消息:join、offer、answer、candidate、leave,外加一个服务端推送的 peer-joined。这里有几个参数值得认真定:

第一个是单条消息大小上限。offer 里的 SDP 通常 2-5KB,candidate 几百字节,64KB 足够。ws 库默认能处理 100MB 的消息,不设限意味着一个坏客户端能把服务器内存拖爆。生产环境建议在 server.js 里显式设置 maxPayload: 65536 。

第二个是心跳间隔。WebSocket 自带 ping/pong,但代理设备和移动网络会静默断连。我一般每 30 秒从服务器主动 ping 一次,连续两次没收到 pong 就判定死连,主动 close 掉,避免 rooms 里堆僵尸连接。demo 阶段可以不加,但你要知道这是上线前必补的一环。

第三个是房间号策略。demo 里用 default 就能跑,但两个人同时用默认房间会互相串线。简单做法是让发起方生成一个 6 位随机字符串当 roomId,通过 URL 发给对方,等于把配对问题变成房间号传递问题。

第四个是消息格式统一。candidate 和 SDP 都塞进 data 字段,服务器无脑转发,客户端按 type 解析。这个约定能让服务器代码始终保持十行以内,也方便以后接消息队列做多机部署。

验证信令服务是否正常,可以用两个终端分别连上去测试。安装 wscat 或者写个几行的 Node 脚本,一个发 join,另一个也发 join,两边都应该收到 peer-joined。收到就说明房间配对通了,可以进下一步。

4. 浏览器端 P2P 传输:用 WebRTC DataChannel 把文件分片传过去

4.1 连接建立:offer/answer 交换与 candidate 缓冲

浏览器端是整个 p2pDemo 的核心。连接建立流程分四步:双方 join 同一房间;发送端创建 RTCPeerConnection 和 DataChannel,生成 offer 发给接收端;接收端收到 offer 后创建自己的 RTCPeerConnection,设置 ondatachannel 回调,生成 answer 回发;双方互相补投递 ICE candidate。第 5 章会讲 candidate 乱序问题,这里先按正确顺序写。

// 浏览器端连接建立:发送端与接收端共用一段逻辑
const wsUrl = "ws://localhost:3001";
const roomId = new URLSearchParams(location.search).get("room") || "demo";
const ws = new WebSocket(wsUrl);
const config = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };

let pc, dc, role;
let pendingCandidates = []; // remoteDescription 设置前先缓存 candidate

function sendSignal(type, data) {
  ws.send(JSON.stringify({ type, roomId, data }));
}

ws.onmessage = async (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "peer-joined") {
    if (role === "sender") await startAsSender();
  } else if (msg.type === "offer") {
    await handleOffer(msg);
  } else if (msg.type === "answer") {
    await pc.setRemoteDescription(new RTCSessionDescription(msg.data.sdp));
    flushCandidates();
  } else if (msg.type === "candidate") {
    if (!pc || !pc.remoteDescription) {
      pendingCandidates.push(msg.data);
    } else {
      await pc.addIceCandidate(new RTCIceCandidate(msg.data));
    }
  }
};

async function startAsSender() {
  pc = new RTCPeerConnection(config);
  dc = pc.createDataChannel("file", { ordered: true });
  setupDataChannel(dc);
  pc.onicecandidate = (e) => {
    if (e.candidate) sendSignal("candidate", e.candidate);
  };
  // 先创建 DataChannel,再 createOffer,顺序不能反
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  sendSignal("offer", { sdp: offer });
}

async function handleOffer(msg) {
  pc = new RTCPeerConnection(config);
  pc.ondatachannel = (e) => setupDataChannel(e.channel);
  pc.onicecandidate = (e) => {
    if (e.candidate) sendSignal("candidate", e.candidate);
  };
  await pc.setRemoteDescription(new RTCSessionDescription(msg.data.sdp));
  flushCandidates();
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  sendSignal("answer", { sdp: answer });
}

function flushCandidates() {
  for (const cand of pendingCandidates) {
    pc.addIceCandidate(new RTCIceCandidate(cand)).catch(console.warn);
  }
  pendingCandidates = [];
}

offer 必须晚于 createDataChannel,这是 WebRTC 里最容易踩的顺序坑。DataChannel 的协商信息在 offer 的 SDP 里,如果先 createOffer 再 createDataChannel,SDP 里就没有 m=application 段,接收端根本不会触发 ondatachannel。flushCandidates 的本质是先把 candidate 攒在数组里,等 remoteDescription 设置完再补投,避免出现“answer 还没到,candidate 先到了”的时序错乱。

房间里的角色怎么定?demo 里我用 URL 参数 room 区分,谁先进房间谁当发送端。更稳妥的做法是加一条 role 消息:join 之后双方先互通角色,再由一方发起连接。文件传输方向只有一方,但如果做双向聊天,角色这个概念就不需要了,任何一方都可以同时发 offer 和收 offer。

4.2 文件分片与重组:背压控制在 1MB 水位

DataChannel 一次 send 一个 ArrayBuffer,浏览器不会帮你把大文件切开。把整个 1GB 文件一次性丢进去,内存直接爆。分片加背压是必须的。发送端用循环读文件切片,每发一片检查 bufferedAmount,超水位就等 bufferedamountlow 事件再继续。

// 发送端核心:按 16KB 分片发送,并做缓冲水位背压
const CHUNK_SIZE = 16 * 1024;
const HIGH_WATER = 8 * 1024 * 1024;  // 8MB,超过就暂停读文件
const LOW_WATER = 4 * 1024 * 1024;   // 低于 4MB 再继续

async function sendFileInChunks(dc, file) {
  const total = Math.ceil(file.size / CHUNK_SIZE);
  dc.send(JSON.stringify({ type: "meta", name: file.name, size: file.size, total }));

  let offset = 0;
  for (let i = 0; i < total; i++) {
    while (dc.bufferedAmount > HIGH_WATER) {
      await new Promise((resolve) => {
        dc.bufferedAmountLowThreshold = LOW_WATER;
        dc.addEventListener("bufferedamountlow", resolve, { once: true });
      });
    }
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    dc.send(chunk);
    offset += CHUNK_SIZE;
    if (i % 100 === 0) console.log(`已发送 ${offset}/${file.size}`);
  }
}

接收端先收 meta 消息拿到文件名和总大小,再收二进制分片。二进制消息要用 dc.binaryType = "arraybuffer" 显式声明类型,否则浏览器默认按 Blob 投递,后面重组时还要多做一次转换。把收到的分片推进 chunks 数组,每收一片累加字节数,直到等于 meta 里声明的 size,再合成 Blob 触发下载。

// 接收端:DataChannel 消息分发与分片重组
let receivedMeta = null;
let receivedBytes = 0;
let chunks = [];

function setupDataChannel(channel) {
  dc = channel;
  dc.binaryType = "arraybuffer";
  dc.onmessage = (e) => {
    if (typeof e.data === "string") {
      const msg = JSON.parse(e.data);
      if (msg.type === "meta") {
        receivedMeta = msg;
        receivedBytes = 0;
        chunks = [];
      }
      return;
    }
    chunks.push(new Uint8Array(e.data));
    receivedBytes += e.data.byteLength;
    if (receivedMeta && receivedBytes === receivedMeta.size) {
      const blob = new Blob(chunks, { type: "application/octet-stream" });
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = receivedMeta.name;
      a.click();
      chunks = [];
    }
  };
}

收完立即触发下载,是为了及时释放内存。Uint8Array 分片在进入 chunks 数组后就脱离了 V8 的逃逸分析,GC 不会立刻回收大块缓冲区,所以最后 chunks = [] 这一步其实是给 GC 一个明确信号。demo 阶段这么写够用;传超过 500MB 的文件,这个方案会撑爆内存,第 5 章会单独讲。

4.3 三个必调参数:ordered、maxRetransmits、bufferedAmount

DataChannel 的可靠性参数决定了你的文件能不能完整到达。默认 ordered: true ,消息按发送顺序到达,代价是丢包后要等重传,后续消息全部排队。传业务文件必须开有序和重传,否则收到的文件是残的。

参数 取值 适用场景 坑
ordered true 文件、聊天记录 丢包率高时吞吐会断崖下跌
maxRetransmits 0 游戏坐标、实时位置 和 ordered 互斥,不能同时设
bufferedAmount 8MB 水位 大文件传输 WebRTC 默认无背压,不设会内存溢出

maxRetransmits 设定重传次数上限,设为 0 表示消息只发一次,适合可以丢帧的实时控制消息。但要注意 RFC 规定 ordered 和 maxRetransmits 不能同时配置,要传文件就用 ordered,要不丢但可以乱序就用 maxRetransmits 加 ordered: false。

CHUNK_SIZE 选 16KB 是经验值。单条消息超过 64KB 是硬上限,太大在网络抖动时重传代价翻倍,太小则协议开销占比升高。做过测试的都知道,16KB 在绝大多数链路上是吞吐和重传开销的平衡点。带宽非常高的内网可以试 32KB,但不要一上来就贪大。

5. 避坑:p2pDemo 最容易翻车的 5 个现场与排查方法

5.1 offer 发出去了,answer 也在,DataChannel 就是打不开

现象:信令正常,两端 ICE 状态都是 connected,但 ondatachannel 始终不触发,DataChannel 的 onopen 也没有被调用,页面看起来建立了连接又什么都没发生。

原因:创建 DataChannel 的时机错了。WebRTC 规定 DataChannel 的协商信息必须携带在 offer 的 SDP 里,如果先 createOffer 再 createDataChannel,SDP 里没有 m=application 段,对端无从知道你要开一条数据通道。

解决:把 pc.createDataChannel("file", { ordered: true }) 挪到 pc.createOffer() 之前,然后重新生成 SDP。排错时先展开 offer 的 SDP 文本,搜索 m=application,找不到这个段就是时序问题。这个坑我在第一次实现时踩了一整天,代码逻辑全部正确,就是两行顺序反了。

5.2 同一个局域网秒连,跨网络永远失败

现象:两个终端连在同一个 WiFi 下,传文件飞快;分别放到两个异地网络后,ICE 状态一直停留在 gathering 或 checking,最后进入 failed。

原因:局域网内 host candidate 直接可达,不需要 NAT 映射参与。跨网络后需要 srflx candidate,而 srflx 能否生成取决于两个条件:STUN 服务器可达、NAT 允许 UDP 入站。任何一个不满足,candidate 列表里就只有 host,公网打洞无从谈起。

解决:先在控制台打印所有 candidate,看有没有 srflx。没有就到 2.2 节的探测代码,单独跑一次 STUN 查询。如果 STUN 也失败,多半是配置了公网 DNS 解析不到或防火墙封了 3478 端口。这一层确认之后,再谈对称型 NAT 的问题。跨网络失败时先怀疑基础连通性,别急着怀疑协议。

5.3 大文件内存暴涨,页面直接卡死

现象:传 200MB 文件时一切正常,传 1GB 文件时接收端页面内存飙升到 2GB,浏览器标签页崩溃。

原因:接收端的 chunks 数组把所有分片都留在内存里,等最后一刻才拼 Blob。1GB 文件要拆成 65536 个 16KB 分片,Uint8Array 的开销加上对齐浪费,实际内存占用能到文件大小的两倍以上。

解决:demo 阶段给文件大小加个上限,超过 100MB 直接提示不支持。生产方案换成 File System Access API 的 createWritable() ,每收到一片就写入磁盘,内存里只保留一个分片。这个 API 需要用户授权,但换来的是内存占用恒定。另一个缓解手段是调大 CHUNK_SIZE 到 64KB,分片数量降到四分之一,但这只是推迟爆炸,不是根除。

5.4 candidate 比 answer 先到,addIceCandidate 抛错

现象:控制台报 Failed to set remote answer ,或者 candidate 添加时提示 remoteDescription is null ,连接卡死。

原因:信令服务器按到达顺序转发消息,candidate 的收集速度可能比 offer/answer 的协商流程更快,浏览器收到 candidate 时 remoteDescription 还没设置,此时调用 addIceCandidate 是非法操作。

解决:在客户端加一个 pendingCandidates 缓冲队列,remoteDescription 为空时把 candidate 先存起来,等 answer 或 offer 的 setRemoteDescription 执行完再统一补投。第 4 章的 flushCandidates 函数就是干这件事的。这里要提醒一点:offer 和 answer 都有可能带 candidate 的时机问题,两条路径都要做缓冲,只处理一条还是会偶发失败。

5.5 P2P 传文件比服务器中转还慢

现象:iceConnectionState 显示 connected,理论上直连已经通了,但吞吐只有几百 KB/s,比服务器中转还慢一个数量级。

原因:大概率选中了 relay 路径。打开 webrtc-internals 看 selected candidate pair,如果里面有 relay 字样,说明这条连接实际经过了 TURN 中继服务器,带宽被中继服务限制,P2P 名存实亡。另一种可能是 UDP 被网络运营商限速,或者丢包率过高导致重传风暴。

解决:先区分是路径问题还是丢包问题。webrtc-internals 里看 packetsSent 和 packetsLost,丢包率超过 2% 就怀疑网络质量。路径是 relay 的话,回第 2 章检查 NAT 类型,对称型 NAT 只靠 STUN 是打不通的,要么接受降级,要么换用基于 TCP 的 ICE transport 策略。还有一个容易被忽略的细节:发送端背压没做,DataChannel 内部缓冲堆满后,WebRTC 自己会强制暂停,吞吐看起来就上不去。

6. 进阶:验证 P2P 吞吐与断线重连的落地技巧

6.1 用 Performance API 测算真实吞吐

文件传完想量化效果,别靠感觉,用 Performance 时间戳算一次真实吞吐。在发送循环里记录开始时间和累计字节数,取总字节数和总耗时相除。注意要等 DataChannel 的 bufferedAmount 归零后再计算,因为 send 只是把数据交进缓冲,不是送到对端。这个数字能直接验证你的分片参数和网络路径是否合理。

const t0 = performance.now();
let totalBytes = 0;

// 每发送一片:totalBytes += chunk.byteLength;
// 全部发完后,等待 dc.bufferedAmount === 0,再结算
await new Promise((resolve) => {
  const timer = setInterval(() => {
    if (dc.bufferedAmount === 0) {
      clearInterval(timer);
      resolve();
    }
  }, 200);
});
const mbps = (totalBytes * 8) / ((performance.now() - t0) / 1000) / 1e6;
console.log(`P2P 吞吐: ${mbps.toFixed(2)} Mbps`);

6.2 监听 connectionstate 做断线重建

移动网络切换、WiFi 瞬断都会让已经建立的 P2P 连接失效。最常用的后悔药是监听 onconnectionstatechange ,状态切到 failed 时把 RTCPeerConnection 整个重建,重新走一遍 offer/answer。断线重连里最容易漏的是旧连接的监听器没清理,新连接建立后事件回调被调用两次,收到重复消息。我习惯在重建前先 pc.ondatachannel = null ,再 pc.close() ,把旧对象的所有回调摘干净。

pc.onconnectionstatechange = () => {
  if (pc.connectionState === "failed") {
    console.warn("连接失败,正在重建...");
    pc.close();
    pc.ondatachannel = null;
    // 重新进入连接建立流程
    handleReconnect();
  }
};

6.3 分片大小与缓冲水位联动调优

最后留一个经验值表。分片大小、缓冲水位、发送并发三者的关系不是孤立的,我通常按链路质量调整:内网环境直接上 32KB 分片、8MB 水位;跨网络先跑一次 6.1 的吞吐测试,丢包率高就退回 16KB 分片并降低高水位到 4MB,减少重传堆积。调参时每次只改一个变量,改完跑同一份测试脚本,对比前后两次的吞吐数字,别凭感觉一次改三处。

断线重连、吞吐验收这些能力其实都不复杂,但它们是 demo 走向可用的分水岭。跑通核心链路只是第一步,把异常路径处理完,这套 p2pDemo 才能塞进真实业务里。我自己每回做完一个 P2P 示例,都会留一份完整的 candidate 日志,后面排查玄学问题时能省半天的力气。希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐