从零开始,手把手带你构建 P2P 网络应用,本文由claude code编写
1. 核心概念扫盲
在写代码之前,先花 5 分钟理解几个关键词。这些概念会贯穿整个教程。
PeerID —— 节点的"身份证"
每个 libp2p 节点在启动时都会生成(或加载)一对密钥(默认是 Ed25519)。PeerID 就是公钥的哈希值,是全网唯一的节点标识。
QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N
Multiaddr —— 节点的"地址簿"
传统 TCP 地址长这样:192.168.1.1:4001。libp2p 的多地址(Multiaddr)更丰富,包含了协议栈信息:
/ip4/192.168.1.1/tcp/4001/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N
└─────────────┘ └────────┘ └─────────────────────────────────────────────────────┘
IP 地址 TCP 端口 PeerID
也可以是:
/ip4/0.0.0.0/tcp/0—— 随机端口的 TCP/ip4/0.0.0.0/udp/0/quic-v1—— QUIC 协议/dns4/example.com/tcp/443/wss—— WebSocket over TLS
Host —— 你的节点实例
host.Host 是 libp2p 的核心对象,可以理解为"这台机器在 P2P 网络中的代理"。所有操作都从它出发:监听连接、发起连接、注册协议处理器等。
Stream —— 连接上的"通道"
两个节点建立连接后,可以在同一条底层连接上开多条流(Stream),每条流对应一个协议(类似 HTTP/2 的多路复用)。
Protocol ID —— 协议的"频道号"
每条流都有一个协议 ID,是一个字符串,用于标识通信双方在"聊什么话题":
const MyProtocol = "/myapp/greet/1.0.0"
连接协议的选择机制
libp2p 建立连接时涉及两层协议协商,理解这个对调试和性能优化很有帮助。
第一层:传输协议(数据怎么在网络上传)
发起方按优先级逐个尝试双方都支持的传输协议,第一个成功的就用:
优先级:QUIC-v1 > TCP > WebSocket > WebTransport
QUIC 可用?──是──→ 用 QUIC(内置加密 + 多路复用,后两层直接跳过)
│
否(UDP 被屏蔽 / 对端未开启)
│
TCP 可用?──是──→ 用 TCP,继续协商下面两层
影响传输协议选择的因素:
- 双方各自的
ListenAddrStrings配置:写了什么协议,才支持什么协议 - 网络环境:部分企业网络或运营商屏蔽 UDP,导致 QUIC 不可用,自动降级 TCP
第二层:安全层 + 多路复用层(仅 TCP 需要,QUIC 已内置)
TCP 连接建立后,还需要握手协商:
安全层(加密): Noise(优先,更轻量) vs TLS 1.3
多路复用层: Yamux(优先,当前默认) vs Mplex(旧版,逐渐废弃)
完整决策链:
两节点建立连接
│
▼
双方都支持 QUIC?
├─ 是 ──→ QUIC(内置 TLS 1.3 + 多路复用)✓ 完成
└─ 否 ──→ TCP
│
▼
安全层协商:Noise 或 TLS 1.3
│
▼
多路复用协商:Yamux 或 Mplex
│
▼
连接建立完成,可以开 Stream ✓
通过代码可以查看实际协商结果:
conns := h.Network().ConnsToPeer(peerID)
for _, conn := range conns {
fmt.Println("传输协议:", conn.RemoteMultiaddr()) // 能看出是 TCP 还是 QUIC
fmt.Println("安全协议:", conn.ConnState().Security) // noise 或 tls
fmt.Println("多路复用:", conn.ConnState().Muxer) // yamux 或 mplex
}
2. 环境准备
# 需要 Go 1.21+
go version
# 新建项目
mkdir p2p-demo && cd p2p-demo
go mod init p2p-demo
# 安装 go-libp2p
go get github.com/libp2p/go-libp2p@latest
依赖会自动拉取,核心依赖项包括:
github.com/libp2p/go-libp2p—— 主库github.com/multiformats/go-multiaddr—— Multiaddr 解析github.com/libp2p/go-libp2p/p2p/net/gostream—— 流工具
3. 第一个节点
最简单的节点,3 行核心代码:
// main.go
package main
import (
"fmt"
"github.com/libp2p/go-libp2p"
)
func main() {
// 创建一个节点,使用默认配置
// 默认会:随机生成密钥、监听 TCP + QUIC、启用 TLS + Noise 加密
h, err := libp2p.New()
if err != nil {
panic(err)
}
defer h.Close()
// 打印节点信息
fmt.Println("我的 PeerID:", h.ID())
fmt.Println("监听地址:")
for _, addr := range h.Addrs() {
fmt.Printf(" %s/p2p/%s\n", addr, h.ID())
}
// 阻塞运行
select {}
}
运行结果:
我的 PeerID: 12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN
监听地址:
/ip4/127.0.0.1/tcp/56789/p2p/12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN
/ip4/192.168.1.5/tcp/56789/p2p/12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN
/ip4/0.0.0.0/udp/56790/quic-v1/p2p/...
注意:每次运行 PeerID 都不同,因为密钥是随机生成的。实际项目中需要持久化密钥,后面会讲。
4. 监听与连接
在 P2P 世界里,其实没有严格的"服务端/客户端"之分——每个节点既可以接受连接,也可以主动连接别人。但为了演示,我们先用"监听节点"和"拨号节点"来类比。
4.1 持久化密钥(重要!)
实际项目中,节点重启后 PeerID 应该不变:
package main
import (
"crypto/rand"
"encoding/base64"
"os"
"github.com/libp2p/go-libp2p/core/crypto"
)
// 加载或生成密钥
func loadOrCreateKey(path string) (crypto.PrivKey, error) {
// 尝试从文件加载
if data, err := os.ReadFile(path); err == nil {
decoded, err := base64.StdEncoding.DecodeString(string(data))
if err != nil {
return nil, err
}
return crypto.UnmarshalPrivateKey(decoded)
}
// 文件不存在,生成新密钥
priv, _, err := crypto.GenerateEd25519Key(rand.Reader)
if err != nil {
return nil, err
}
// 序列化并保存
raw, err := crypto.MarshalPrivateKey(priv)
if err != nil {
return nil, err
}
encoded := base64.StdEncoding.EncodeToString(raw)
os.WriteFile(path, []byte(encoded), 0600)
return priv, nil
}
4.2 监听节点(Listener)
// listener/main.go
package main
import (
"fmt"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/network"
)
const GreetProtocol = "/demo/greet/1.0.0"
func main() {
// 使用固定端口,便于对端连接
h, err := libp2p.New(
libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/9000"),
)
if err != nil {
panic(err)
}
defer h.Close()
// 注册协议处理器
// 当有对端用 GreetProtocol 打开一条流时,这个函数会被调用
h.SetStreamHandler(GreetProtocol, func(s network.Stream) {
defer s.Close()
// 读取对端发来的消息
buf := make([]byte, 1024)
n, err := s.Read(buf)
if err != nil {
fmt.Println("读取错误:", err)
return
}
msg := string(buf[:n])
fmt.Printf("[收到消息] 来自 %s: %s\n", s.Conn().RemotePeer(), msg)
// 回复
s.Write([]byte("你好!我收到你的消息了。"))
})
fmt.Printf("监听节点已启动\nPeerID: %s\n地址: /ip4/127.0.0.1/tcp/9000/p2p/%s\n",
h.ID(), h.ID())
select {}
}
4.3 拨号节点(Dialer)
// dialer/main.go
package main
import (
"fmt"
"os"
"github.com/libp2p/go-libp2p"
"github.com/multiformats/go-multiaddr"
"github.com/libp2p/go-libp2p/core/peer"
)
const GreetProtocol = "/demo/greet/1.0.0"
func main() {
if len(os.Args) < 2 {
fmt.Println("用法: go run main.go <监听节点的完整 multiaddr>")
fmt.Println("例如: go run main.go /ip4/127.0.0.1/tcp/9000/p2p/12D3Koo...")
os.Exit(1)
}
h, err := libp2p.New()
if err != nil {
panic(err)
}
defer h.Close()
// 解析对端地址
targetAddr, err := multiaddr.NewMultiaddr(os.Args[1])
if err != nil {
panic(err)
}
targetInfo, err := peer.AddrInfoFromP2pAddr(targetAddr)
if err != nil {
panic(err)
}
// 连接对端(libp2p 会自动选择最优传输协议)
ctx := context.Background()
if err := h.Connect(ctx, *targetInfo); err != nil {
panic(fmt.Sprintf("连接失败: %v", err))
}
fmt.Println("已连接到:", targetInfo.ID)
// 打开一条流,指定协议
s, err := h.NewStream(ctx, targetInfo.ID, GreetProtocol)
if err != nil {
panic(err)
}
defer s.Close()
// 发送消息
s.Write([]byte("你好,我是拨号节点!"))
// 读取回复
buf := make([]byte, 1024)
n, _ := s.Read(buf)
fmt.Println("收到回复:", string(buf[:n]))
}
运行步骤:
# 终端 1:启动监听节点
go run listener/main.go
# 输出: 地址: /ip4/127.0.0.1/tcp/9000/p2p/12D3KooW...
# 终端 2:启动拨号节点,复制上面的地址
go run dialer/main.go /ip4/127.0.0.1/tcp/9000/p2p/12D3KooW...
5. 流通信
上一节展示了基本的单次消息交换。实际场景中,你可能需要持续的双向通信,比如实现一个简单的 echo 服务。
5.1 使用 bufio 实现行协议
// 服务端:逐行读取并回显
h.SetStreamHandler("/demo/echo/1.0.0", func(s network.Stream) {
defer s.Close()
reader := bufio.NewReader(s)
writer := bufio.NewWriter(s)
for {
// 读一行(以 \n 结尾)
line, err := reader.ReadString('\n')
if err != nil {
break
}
fmt.Printf("Echo: %s", line)
// 写回去
writer.WriteString("ECHO: " + line)
writer.Flush()
}
})
5.2 使用 goroutine 实现并发读写
P2P 应用常见模式:一个 goroutine 负责从流读数据,另一个负责写:
func handleStream(s network.Stream) {
defer s.Close()
// 读 goroutine
go func() {
buf := make([]byte, 4096)
for {
n, err := s.Read(buf)
if err != nil {
return
}
fmt.Printf("[收到] %s\n", buf[:n])
}
}()
// 写 goroutine(从标准输入读取并发送)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
s.Write(append(scanner.Bytes(), '\n'))
}
}
5.3 协议版本协商
libp2p 内置多协议协商(multistream-select),你可以同时注册多个版本的协议,对端会自动选择双方都支持的最新版本:
// 注册 v1 和 v2 两个版本
h.SetStreamHandler("/myapp/chat/1.0.0", handleV1)
h.SetStreamHandler("/myapp/chat/2.0.0", handleV2)
// 客户端连接时,按优先级列出期望协议
// libp2p 会自动协商出双方都支持的最高版本
s, err := h.NewStream(ctx, peerID, "/myapp/chat/2.0.0", "/myapp/chat/1.0.0")
6. 节点发现(mDNS)
在局域网内,如何让两个节点互相找到对方?使用 mDNS(多播 DNS),无需任何中心化服务器。
package main
import (
"context"
"fmt"
"sync"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/host"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
)
// 实现 mdns.Notifee 接口:当发现新节点时被调用
type discoveryNotifee struct {
h host.Host
mu sync.Mutex
}
func (n *discoveryNotifee) HandlePeerFound(pi peer.AddrInfo) {
n.mu.Lock()
defer n.mu.Unlock()
// 不连接自己
if pi.ID == n.h.ID() {
return
}
fmt.Printf("[发现新节点] %s\n", pi.ID)
// 自动连接发现的节点
if err := n.h.Connect(context.Background(), pi); err != nil {
fmt.Printf("连接失败: %v\n", err)
return
}
fmt.Printf("[已连接] %s\n", pi.ID)
}
func main() {
h, err := libp2p.New()
if err != nil {
panic(err)
}
defer h.Close()
fmt.Println("本节点 ID:", h.ID())
// 启动 mDNS 发现服务
// "my-p2p-app" 是服务名,同名的节点才会互相发现
notifee := &discoveryNotifee{h: h}
svc := mdns.NewMdnsService(h, "my-p2p-app", notifee)
if err := svc.Start(); err != nil {
panic(err)
}
defer svc.Close()
fmt.Println("mDNS 发现服务已启动,等待局域网内的节点...")
select {}
}
测试方法:在同一局域网的两台机器(或同一台机器开两个终端)运行此程序,它们会自动互相发现并连接,无需手动指定地址。
7. 广域网节点寻址
这是一个非常关键的问题,也是 P2P 开发者最常感到困惑的地方:
端口可以是随机的,客户端怎么找到我?
答案是:P2P 不靠"固定端口"定位服务,靠"PeerID + 发现网络"。端口随机没关系,只要有办法把你的当前地址告诉网络里的其他节点就行。这和传统服务端开发的思维是一次根本性的转变。
7.1 三种寻址场景对比
| 场景 | 方案 | 是否需要固定地址 |
|---|---|---|
| 局域网 | mDNS 自动发现 | 不需要 |
| 广域网 / 公共 P2P 网络 | Kademlia DHT | 只需要 Bootstrap 节点固定 |
| 私有 P2P 服务 | 自建 Bootstrap + DHT | 只需要你的 Bootstrap 节点固定 |
7.2 Bootstrap 节点 —— 网络的"入口黄页"
Bootstrap 节点是整个 P2P 网络的集合点,它的地址硬编码在客户端代码中(类似 DNS 根服务器的角色)。客户端启动时先连上它,通过它认识网络里的其他节点。
关键点:只有 Bootstrap 节点需要固定地址,其他所有普通节点的地址和端口都可以动态变化。
你的服务器 (1.2.3.4:4001) ← 只有这一个地址是固定的,写死在客户端里
↑
所有节点启动时先连这里
↓
通过 DHT 路由,节点之间互相发现,不再依赖 Bootstrap
7.3 Kademlia DHT —— 分布式路由表
DHT(分布式哈希表)是真正解决"如何通过 PeerID 找到节点地址"的机制。每个节点维护一张局部路由表,通过多跳查询,最终能定位到网络中任意一个 PeerID 对应的地址。
完整示例:
package main
import (
"context"
"fmt"
"time"
"github.com/libp2p/go-libp2p"
dht "github.com/libp2p/go-libp2p-kad-dht"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/multiformats/go-multiaddr"
)
// 硬编码的 Bootstrap 节点地址列表(这是 IPFS 公共网络的 Bootstrap)
// 私有网络请替换为你自己的节点地址
var defaultBootstrapPeers = []string{
"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
"/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
"/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
}
func main() {
ctx := context.Background()
h, err := libp2p.New(
libp2p.ListenAddrStrings(
"/ip4/0.0.0.0/tcp/0",
"/ip4/0.0.0.0/udp/0/quic-v1",
),
)
if err != nil {
panic(err)
}
defer h.Close()
fmt.Println("本节点 PeerID:", h.ID())
// 1. 连接 Bootstrap 节点,加入网络
for _, addrStr := range defaultBootstrapPeers {
ma, err := multiaddr.NewMultiaddr(addrStr)
if err != nil {
continue
}
pi, err := peer.AddrInfoFromP2pAddr(ma)
if err != nil {
continue
}
if err := h.Connect(ctx, *pi); err != nil {
fmt.Printf("连接 Bootstrap 节点失败 %s: %v\n", pi.ID, err)
} else {
fmt.Printf("已连接 Bootstrap 节点: %s\n", pi.ID)
}
}
// 2. 创建 Kademlia DHT,开始参与路由
// dht.ModeServer 表示本节点也愿意帮其他节点转发查询(公网节点推荐)
// dht.ModeClient 表示只查询不转发(NAT 后的节点推荐)
kadDHT, err := dht.New(ctx, h, dht.Mode(dht.ModeServer))
if err != nil {
panic(err)
}
// 3. Bootstrap DHT:填充路由表(异步进行)
if err := kadDHT.Bootstrap(ctx); err != nil {
panic(err)
}
// 等待路由表填充完毕
fmt.Println("正在填充路由表...")
time.Sleep(5 * time.Second)
fmt.Printf("路由表中已有 %d 个节点\n", kadDHT.RoutingTable().Size())
// 4. 通过 PeerID 查找任意节点的地址
// 无论目标节点的端口是什么,只要知道 PeerID 就能找到它
targetIDStr := "12D3KooWTargetPeerID..." // 替换为真实的 PeerID
targetID, err := peer.Decode(targetIDStr)
if err != nil {
panic(err)
}
fmt.Printf("正在查找节点 %s ...\n", targetID)
peerInfo, err := kadDHT.FindPeer(ctx, targetID)
if err != nil {
fmt.Println("未找到节点:", err)
return
}
fmt.Printf("找到节点!地址: %v\n", peerInfo.Addrs)
// 5. 直接连接
if err := h.Connect(ctx, peerInfo); err != nil {
fmt.Println("连接失败:", err)
return
}
fmt.Println("连接成功!")
}
7.4 自建私有 Bootstrap 节点
如果你在做私有 P2P 服务,不想依赖公共网络,需要自己搭一个 Bootstrap 节点:
// bootstrap/main.go —— 部署在公网服务器上,使用固定端口
package main
import (
"fmt"
"github.com/libp2p/go-libp2p"
dht "github.com/libp2p/go-libp2p-kad-dht"
)
func main() {
// 加载持久化密钥,确保 PeerID 重启后不变
priv, _ := loadOrCreateKey("bootstrap.key")
h, err := libp2p.New(
libp2p.Identity(priv),
// 固定端口!这是整个私有网络唯一需要固定的地址
libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/4001"),
)
if err != nil {
panic(err)
}
// Bootstrap 节点也运行 DHT,帮助其他节点路由
_, err = dht.New(context.Background(), h, dht.Mode(dht.ModeServer))
if err != nil {
panic(err)
}
// 打印出其他节点需要写入代码的地址
fmt.Printf("Bootstrap 节点已启动,请将以下地址硬编码到客户端:\n")
fmt.Printf("/ip4/<你的公网IP>/tcp/4001/p2p/%s\n", h.ID())
select {}
}
客户端代码里只需要写死这一行地址:
// 你的私有网络 Bootstrap 节点(只有这里是固定的)
var myBootstrapPeers = []string{
"/ip4/1.2.3.4/tcp/4001/p2p/12D3KooWYourBootstrapNodeID...",
}
7.5 寻址流程总结
客户端启动
│
▼
连接 Bootstrap 节点(唯一硬编码的固定地址)
│
▼
Bootstrap 响应:返回它知道的邻近节点列表
│
▼
Kademlia DHT Bootstrap:递归查询,填充本地路由表
│
▼
调用 FindPeer(targetPeerID)
│ 每一跳都问"你认识比我更接近目标的节点吗?"
▼
获得目标节点的当前 Multiaddr(含实际 IP 和端口)
│
▼
直接连接目标节点 ✓
核心思维转变:传统后端靠"固定 IP:端口"定位服务;P2P 靠"PeerID + Bootstrap 入口"定位节点。端口随机完全没问题,DHT 会帮你找到它。
8. NAT 穿透
8.0 先搞清楚几个角色的关系
这一章涉及多个概念,先把它们的关系理清楚,否则很容易混淆。
NAT 节点不是一种方案,而是一种"处境"——指那些藏在家用路由器或公司防火墙后面、没有独立公网 IP 的节点。它面临两个独立的问题:
- 被发现:别人怎么知道我的存在?→ 由上一章的 Bootstrap + DHT 解决
- 被连接:别人找到我了,但 NAT 挡住了直连请求怎么办?→ 由本章解决
解决"被连接"问题,libp2p 引入了两种公网节点角色:
| 角色 | 职责 | 类比 |
|---|---|---|
| 协调节点(DCUtR) | 帮两个 NAT 后的节点交换外网地址,协调同时打洞 | 婚介所:撮合双方,自己不参与 |
| 中继节点(Circuit Relay) | 打洞失败时,充当数据转发的中间人 | 快递中转站:流量经过它转发 |
关键点:一台公网服务器可以同时充当多种角色。 你部署的那台服务器完全可以同时是 Bootstrap + 协调节点 + 中继节点,代码里叠加配置即可(后面会展示)。
完整的工作流:
NAT节点 A 启动
│
├─① 连 Bootstrap,加入 DHT 网络(被其他节点"发现")
│
│ NAT节点 B 想连接 A
│
├─② B 通过 DHT 查到 A 的地址(但直连被 NAT 拒绝)
│
├─③ B 通过"中继地址"先借道中继节点与 A 建立初始连接
│ 地址形如: /ip4/<relay>/tcp/4001/p2p/<relay-id>/p2p-circuit/p2p/<A-id>
│
├─④ 连上后,双方通过协调节点尝试打洞(DCUtR 协议)
│ → 打洞成功:升级为直连,中继连接废弃 ✓
│ → 打洞失败:继续走中继转发(性能差但能用)✓
│
└─ 最终状态:直连 或 中继转发
8.1 AutoNAT —— 自动探测自己是否可达
节点启动后,它并不知道自己是否在 NAT 后面。AutoNAT 会请求网络中的其他节点从外部来尝试连接自己,从而判断可达性:
h, err := libp2p.New(
libp2p.EnableNATService(),
)
开启后可以查询结果:
// 订阅可达性变化事件
sub, _ := h.EventBus().Subscribe(new(event.EvtLocalReachabilityChanged))
go func() {
for e := range sub.Out() {
evt := e.(event.EvtLocalReachabilityChanged)
switch evt.Reachability {
case network.ReachabilityPublic:
fmt.Println("我有公网 IP,可以被直连")
case network.ReachabilityPrivate:
fmt.Println("我在 NAT 后面,需要打洞或中继")
case network.ReachabilityUnknown:
fmt.Println("还在探测中...")
}
}
}()
8.2 公网服务节点 —— Bootstrap + 协调 + 中继三合一
在私有网络中,你通常只有一台公网服务器。下面的代码展示如何让它同时承担三个角色:
// server/main.go —— 部署在公网服务器上
package main
import (
"context"
"fmt"
"github.com/libp2p/go-libp2p"
dht "github.com/libp2p/go-libp2p-kad-dht"
relayv2 "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
)
func main() {
// 加载持久化密钥,PeerID 重启后不变
priv, _ := loadOrCreateKey("server.key")
h, err := libp2p.New(
libp2p.Identity(priv),
libp2p.ListenAddrStrings("/ip4/0.0.0.0/tcp/4001"),
// 角色①:协调节点
// EnableHolePunching 使本节点能够协助其他节点完成打洞握手
libp2p.EnableHolePunching(),
// 角色②:AutoNAT 服务
// 帮其他节点探测自己是否可被外网访问
libp2p.EnableNATService(),
)
if err != nil {
panic(err)
}
defer h.Close()
// 角色③:中继节点
// 为打洞失败的节点提供流量转发
_, err = relayv2.New(h)
if err != nil {
panic(err)
}
// 角色④:Bootstrap / DHT 节点
// 帮助新节点加入网络、参与路由
_, err = dht.New(context.Background(), h, dht.Mode(dht.ModeServer))
if err != nil {
panic(err)
}
fmt.Println("公网服务节点已启动(Bootstrap + 协调 + 中继)")
fmt.Printf("请将以下地址写入客户端代码:\n")
fmt.Printf("/ip4/<你的公网IP>/tcp/4001/p2p/%s\n", h.ID())
select {}
}
8.3 NAT 后的节点 —— 自动完成打洞或中继
// client/main.go —— 运行在 NAT 后的普通节点
package main
import (
"context"
"fmt"
"time"
"github.com/libp2p/go-libp2p"
dht "github.com/libp2p/go-libp2p-kad-dht"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/multiformats/go-multiaddr"
)
// 公网服务节点地址(硬编码)
const serverAddr = "/ip4/1.2.3.4/tcp/4001/p2p/12D3KooWServerID..."
func main() {
ctx := context.Background()
serverInfo := mustParseAddr(serverAddr)
h, err := libp2p.New(
libp2p.ListenAddrStrings(
"/ip4/0.0.0.0/tcp/0",
"/ip4/0.0.0.0/udp/0/quic-v1", // QUIC 对 NAT 穿透更友好
),
// 启用打洞:连上对端后自动尝试升级为直连
libp2p.EnableHolePunching(),
// 启用自动中继:
// 打洞前借道中继建立初始连接;打洞失败时持续走中继
libp2p.EnableAutoRelayWithStaticRelays(
[]peer.AddrInfo{*serverInfo},
),
// 尝试 UPnP 端口映射(家用路由器如果支持,可直接获得公网端口)
libp2p.NATPortMap(),
)
if err != nil {
panic(err)
}
defer h.Close()
// 第一步:连接公网服务节点(Bootstrap)
if err := h.Connect(ctx, *serverInfo); err != nil {
panic("无法连接服务节点: " + err.Error())
}
fmt.Println("已连接服务节点")
// 第二步:启动 DHT,加入网络
kadDHT, _ := dht.New(ctx, h, dht.Mode(dht.ModeClient))
kadDHT.Bootstrap(ctx)
// 等待中继地址出现在自己的地址列表中
// 这意味着其他节点可以通过中继地址找到并连接我
fmt.Println("等待获取中继地址...")
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
for _, addr := range h.Addrs() {
// 中继地址包含 p2p-circuit 字样
if strings.Contains(addr.String(), "p2p-circuit") {
fmt.Println("已获取中继地址:", addr)
fmt.Println("现在其他节点可以通过中继连接到我了")
goto ready
}
}
}
ready:
// 第三步:通过 PeerID 连接另一个 NAT 后的节点
// libp2p 会自动:先走中继建连 → 再尝试打洞升级直连
targetID, _ := peer.Decode("12D3KooWTargetNATNodeID...")
targetInfo, err := kadDHT.FindPeer(ctx, targetID)
if err != nil {
fmt.Println("未找到目标节点:", err)
return
}
if err := h.Connect(ctx, targetInfo); err != nil {
fmt.Println("连接失败:", err)
return
}
// 查看最终连接用的是直连还是中继
conns := h.Network().ConnsToPeer(targetID)
for _, conn := range conns {
fmt.Printf("连接方式: %s\n", conn.RemoteMultiaddr())
// 直连地址: /ip4/x.x.x.x/udp/xxxxx/quic-v1
// 中继地址: /ip4/<relay>/tcp/4001/.../p2p-circuit
}
}
func mustParseAddr(s string) *peer.AddrInfo {
ma, _ := multiaddr.NewMultiaddr(s)
pi, _ := peer.AddrInfoFromP2pAddr(ma)
return pi
}
8.4 打洞成功 vs 中继的判断
连接建立后,你可以通过地址判断走的是哪条路:
conns := h.Network().ConnsToPeer(targetPeerID)
for _, conn := range conns {
addr := conn.RemoteMultiaddr().String()
if strings.Contains(addr, "p2p-circuit") {
fmt.Println("⚠️ 走中继转发,延迟较高")
} else {
fmt.Println("✅ 直连成功,延迟最优")
}
}
也可以监听连接升级事件(打洞成功时 libp2p 会自动从中继切换到直连):
h.Network().Notify(&network.NotifyBundle{
ConnectedF: func(n network.Network, conn network.Conn) {
fmt.Printf("新连接: %s via %s\n",
conn.RemotePeer().ShortString(),
conn.RemoteMultiaddr())
},
})
8.5 两个 NAT 节点连通后,走的是直连还是中继?
这取决于打洞是否成功。libp2p 的策略是:先借道中继建立初始连接,同时在后台尝试打洞,打洞成功则自动升级为直连。
打洞能否成功,本质上取决于双方 NAT 的类型:
节点 A(锥形 NAT) + 节点 B(锥形 NAT)
└── 双方同时向对方外网地址发包 ──→ 各自的 NAT 打开临时洞 ──→ 直连 ✅
节点 A(锥形 NAT) + 节点 B(对称 NAT)
└── 对称 NAT 每次连接用不同外网端口,打洞无法预测端口 ──→ 失败,走中继 ❌
实际打洞成功率大致分布:
| 网络类型 | 大约占比 | 打洞结果 |
|---|---|---|
| 家用宽带(锥形 NAT) | ~70% | ✅ 大概率直连 |
| 移动网络 4G/5G(运营商级 NAT) | ~20% | ⚠️ 成功率较低 |
| 企业/机构网络(对称 NAT) | ~10% | ❌ 基本失败,走中继 |
所以现实中,大多数情况可以直连,少数情况自动降级走中继。你的代码无需关心走的是哪条路,libp2p 会全程自动处理。
8.6 各方案汇总对比
| 方案 | 适用场景 | 连接延迟 | 带宽 | 是否需要公网节点 |
|---|---|---|---|---|
| 直连 | 双方都有公网 IP | 最低 | 最高 | 否 |
| UPnP 端口映射 | 支持 UPnP 的家用路由器 | 低 | 高 | 否 |
| 打洞(Hole Punching) | 锥形 NAT | 低 | 高 | 是(协调用) |
| 中继(Circuit Relay) | 对称 NAT / 打洞失败 | 高(绕道中继) | 受中继限制 | 是(转发用) |
实践建议:同时开启
NATPortMap+EnableHolePunching+EnableAutoRelay,让 libp2p 按优先级自动选择最优路径,无需手动判断。传输协议上优先配置 QUIC(UDP),因为 QUIC 对 NAT 更友好,打洞成功率也更高。
9. PubSub 消息广播
libp2p 提供了 GossipSub 协议,实现去中心化的消息发布/订阅。这是以太坊信标链等区块链项目的核心通信机制。
package main
import (
"context"
"fmt"
"time"
"github.com/libp2p/go-libp2p"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"github.com/libp2p/go-libp2p/p2p/discovery/mdns"
)
const topicName = "my-chat-room"
func main() {
ctx := context.Background()
h, err := libp2p.New()
if err != nil {
panic(err)
}
defer h.Close()
// 创建 GossipSub 实例
ps, err := pubsub.NewGossipSub(ctx, h)
if err != nil {
panic(err)
}
// 加入一个 topic(频道)
topic, err := ps.Join(topicName)
if err != nil {
panic(err)
}
defer topic.Close()
// 订阅消息
sub, err := topic.Subscribe()
if err != nil {
panic(err)
}
// 在 goroutine 中持续接收消息
go func() {
for {
msg, err := sub.Next(ctx)
if err != nil {
return
}
// 过滤自己发出的消息
if msg.ReceivedFrom == h.ID() {
continue
}
fmt.Printf("[%s 说]: %s\n", msg.ReceivedFrom.ShortString(), string(msg.Data))
}
}()
// 启动 mDNS 让节点互相发现
notifee := &autoConnectNotifee{h: h}
svc := mdns.NewMdnsService(h, topicName, notifee)
svc.Start()
// 每 3 秒广播一条消息
ticker := time.NewTicker(3 * time.Second)
i := 0
for range ticker.C {
msg := fmt.Sprintf("消息 #%d 来自 %s", i, h.ID().ShortString())
if err := topic.Publish(ctx, []byte(msg)); err != nil {
fmt.Println("发布失败:", err)
} else {
fmt.Printf("[我发送]: %s\n", msg)
}
i++
}
}
GossipSub 的消息传播是 Gossip 协议:每个节点只将消息转发给一部分邻居,消息像"流言"一样在网络中扩散,最终覆盖全部节点,且无需中心化消息队列。
10. 最佳实践
10.1 资源管理器(Resource Manager)
libp2p 内置了资源限制系统,防止被恶意节点耗尽资源:
import "github.com/libp2p/go-libp2p/p2p/net/swarm"
// 使用默认的资源限制(推荐生产环境开启)
h, err := libp2p.New(
libp2p.ResourceManager(nil), // nil 表示使用默认限制
)
10.2 连接管理器(Connection Manager)
控制节点同时维持的连接数:
import connmgr "github.com/libp2p/go-libp2p/p2p/net/connmgr"
cm, _ := connmgr.NewConnManager(
50, // 低水位:低于此数量时不断连
100, // 高水位:超过此数量时主动断开不活跃连接
connmgr.WithGracePeriod(time.Minute),
)
h, err := libp2p.New(
libp2p.ConnectionManager(cm),
)
10.3 安全传输
默认情况下,所有流量都已加密(TLS 1.3 或 Noise 协议)。但要注意:
// 同时支持 TLS 和 Noise(默认就是这样)
// libp2p 会在握手时协商使用哪种
h, err := libp2p.New(
libp2p.Security(noise.ID, noise.New), // Noise(更轻量)
libp2p.Security(tls.ID, tls.New), // TLS 1.3
)
10.4 优先使用 QUIC
QUIC 相比 TCP 有明显优势:0-RTT 建连、内置加密、无队头阻塞、对 NAT 更友好:
h, err := libp2p.New(
libp2p.ListenAddrStrings(
"/ip4/0.0.0.0/udp/0/quic-v1", // 优先 QUIC
"/ip4/0.0.0.0/tcp/0", // 降级到 TCP
),
)
10.5 错误处理与 Context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 连接超时控制
if err := h.Connect(ctx, peerInfo); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("连接超时")
} else {
fmt.Println("连接失败:", err)
}
}
10.6 监控与指标
libp2p 内置了 Prometheus 指标支持:
import "github.com/prometheus/client_golang/prometheus"
// 注册 libp2p 指标到 Prometheus
h, err := libp2p.New(
libp2p.PrometheusRegisterer(prometheus.DefaultRegisterer),
)
// 暴露 /metrics 端点
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9090", nil)
总结
节点发现 连接建立 流通信 应用层
│ │ │ │
mDNS (局域网) Connect() NewStream() 自定义协议
DHT (广域网) +自动协商传输协议 +协议协商 PubSub
Bootstrap +加密握手 +多路复用
| 场景 | 推荐方案 |
|---|---|
| 局域网节点发现 | mDNS(自动,无需配置) |
| 广域网节点发现 | Kademlia DHT + Bootstrap 节点 |
| 私有网络入口 | 自建 Bootstrap 节点(固定公网地址) |
| 通过 PeerID 定位节点 | kadDHT.FindPeer(ctx, peerID) |
| NAT 穿透 | QUIC + Hole Punching + Circuit Relay(兜底) |
| 广播消息 | GossipSub |
| 一对一通信 | 直接 Stream |
| 生产环境 | 开启 ResourceManager + ConnectionManager |
go-libp2p 的设计哲学是模块化——你可以像搭积木一样,只引入自己需要的传输层、安全层、发现机制。从一个简单的局域网 demo 到以太坊级别的生产系统,它都能胜任。
参考资料: