跨网段就得“投降”走中转?不!把分布式网络、NAT 穿透、RPC 机制一手拿下!
老实说,“同一账号多设备互联”做到局域网(同网段)很轻松,一出小区网关就“失联”,多数团队下意识地全量走云中转,既贵又卡。问题不在天时地利,问题在“没有把控制面与数据面拆开”“没把候选链路排兵布阵”“RPC 层没做成可插拔这篇就沿着你给的大纲,分布式网络 → NAT 穿透 → RPC 机制,基于鸿蒙场景给出能直接落地的模块骨架:上层用IPCProxy做到“像调本地接口一样调远端”,下层用一个抽象的
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
老实说,“同一账号多设备互联”做到局域网(同网段)很轻松,一出小区网关就“失联”,多数团队下意识地全量走云中转,既贵又卡。问题不在天时地利,问题在“没有把控制面与数据面拆开”“没把候选链路排兵布阵”“RPC 层没做成可插拔”。
这篇就沿着你给的大纲,分布式网络 → NAT 穿透 → RPC 机制,基于鸿蒙场景给出能直接落地的模块骨架:上层用 IPCProxy 做到“像调本地接口一样调远端”,下层用一个抽象的 DistributedNetwork 把 SoftBus/本地直连/跨网洞穿/云中继 排成阶梯式优先级。写完你会发现:跨网段不必等于“全部走云”,最贵的路只做兜底。💪
总图先立起来:三层两面,心里有数
-
应用层:你的业务服务(KV、文件、白板、媒体控制…)
-
RPC 层:
IPCProxy(调用方) ↔IPCStub(被调方),可在任意“传输”上跑 -
传输层(数据面):
DistributedNetwork聚合多种“通道”- L0:DSoftBus / Nearby(同网段/近距优先)
- L1:直连 UDP(NAT 打洞成功)
- L2:直连 TCP/QUIC(对称 NAT 或防火墙受限时的 TCP 同时打开/QUIC)
- L3:云中继 WebSocket/TURN(永远兜底)
-
控制面:设备发现、会话信令、候选收集与协商(像简化版的 ICE)
-
安全面:设备身份、密钥交换、流量加密(Noise/TLS)
策略一句话:“近优、廉优、稳优,贵的最后上”。先 SoftBus 探测,再 UDP 打洞,不行才走云。
一、分布式网络(DistributedNetwork):把链路做成“可编排”的梯子
1.1 模块职责
- 统一的 Transport 抽象:
connect/send/close/onMessage - 策略调度:按照优先级并行探测,择优建流 + 故障切换
- 端到端加密:握手后派生会话密钥
- 多路复用:一条底层连接跑多个逻辑通道(RPC/信令/文件)
1.2 传输抽象与实现(ArkTS/示意)
// network/Transport.ts
export interface Transport {
readonly name: string
connect(target: Endpoint, opts?: ConnectOptions): Promise<void>
send(data: Uint8Array): Promise<void>
close(code?: number): Promise<void>
onMessage?: (data: Uint8Array) => void
onClose?: (reason?: string) => void
}
export interface Endpoint {
deviceId: string
// 候选地址:SoftBus-id、(ip,port,proto)、relayUrl...
candidates: Candidate[]
}
export type Candidate =
| { type: 'softbus', busName: string }
| { type: 'udp', ip: string, port: number }
| { type: 'tcp', ip: string, port: number }
| { type: 'quic', ip: string, port: number }
| { type: 'relay', url: string, token: string }
export interface ConnectOptions { timeoutMs?: number }
// network/transports/SoftBusTransport.ts(示意:包装鸿蒙分布式/近距能力)
export class SoftBusTransport implements Transport { /* connect by busName … */ name='softbus' /* … */ }
// network/transports/UdpTransport.ts(示意:打洞成功后走 UDP)
export class UdpTransport implements Transport { /* dgram-like,握手后加密帧 … */ name='udp' /* … */ }
// network/transports/RelayWsTransport.ts(兜底:云中继)
export class RelayWsTransport implements Transport { /* WebSocket over TLS … */ name='relay-ws' /* … */ }
1.3 策略调度器
// network/DistributedNetwork.ts
export class DistributedNetwork {
private tr?: Transport
async smartConnect(ep: Endpoint, deadlineMs=6000) {
// 并行发起:SoftBus(0ms), UDP-Hole(0ms), TCP/QUIC(300ms), Relay(800ms)
const attempts = [
() => this.trySoftBus(ep),
() => this.tryUdpHole(ep),
() => this.tryTcpOrQuic(ep),
() => this.tryRelay(ep),
]
const winner = await raceWithStagger(attempts, deadlineMs)
this.tr = winner
return winner
}
send(data: Uint8Array) { return this.tr?.send(data) }
close() { return this.tr?.close() }
// ……各 tryXxx 内部会用相应 Transport.connect 并完成握手 + 加密
}
这一步的意义是:你的上层不需要知道“我现在走哪条路”,
DistributedNetwork会自动选最优并在出问题时切换。
二、NAT 穿透:跨网段的“关键一脚”
2.1 思维框架:小型 ICE
-
候选收集:每端向“信令服务”上报
- 内网地址(多网卡)
- 公网候选(经 STUN 获取)
- 中继候选(云 relay/TURN)
-
连通性检查:UDPHole → TCP Simultaneous Open/QUIC → Relay
-
保活:UDP 打洞成功后 15–25s 心跳,TCP/QUIC 也要 keepalive
-
NAT 类型差异:
- Full/Restricted Cone:UDP 打洞成功率高
- Symmetric NAT:基本上需要中继或 QUIC/TCP 的某些运气
2.2 穿透握手流程(简化时序)
A,B 向信令服务注册: 交换 candidates + ufrag/pwd(或临时 token)
并行尝试:
1) UDP:A→B 发穿透包、B→A 回打洞包,多对多探测
2) 若失败,尝试 TCP 同时打开 or QUIC
3) 若仍失败,双方连 Relay(WebSocket/TURN),由 Relay 转发数据
选择最先成功的通道,公布为 primary,其他降级为备用
2.3 打洞实现(示意)
// network/holepunch/UdpHolePuncher.ts
export class UdpHolePuncher {
async punch(local: UdpSocket, candA: UdpCand, candB: UdpCand, timeoutMs=2000): Promise<{ip:string,port:number}> {
// 并发向对方所有 UDP 候选发“同步包”
const tickets = allPairs(candA, candB).map(pair => tryPair(local, pair))
const ok = await Promise.any(tickets.map(t => withTimeout(t, timeoutMs)))
return ok // 返回可用对端 (ip,port)
}
}
要点:并行 + 收敛;不要单点串行试错,超时一长,体验就“漏拍”。
2.4 失败预案:中继不丢脸,丢的是体验
- 优先选 就近地域 的中继(延迟敏感业务甚至自建省内/城域节点)
- 数据面用 WebSocket over TLS 或 TURN/UDP
- 费用可控:只在无法直连时启用,业务层感知“通道类型”,比如白板/文本的阈值 <100ms,用 relay 也可接受;音视频则提醒用户“网络不佳自动降质”。
三、RPC 机制:IPCProxy 让“远端像本地”
3.1 为什么别直接塞 JSON 字符串
- 需要 请求-响应配对(
id) - 需要 流控与背压(避免一端溢出)
- 需要 方法版本/扩展字段(向后兼容)
- 需要 压缩/二进制(CBOR/Protobuf,比 JSON 省带宽)
3.2 抽象:可插拔传输上的通道复用
// rpc/Framing.ts —— 简易帧/路由
export interface Frame {
ch: number // channel id
type: 'req'|'res'|'push'
id?: number
method?: string
payload?: Uint8Array
ok?: boolean
err?: string
}
// rpc/IPCProxy.ts —— 生成“远程对象”的代理
export class IPCProxy<T extends object> {
private nextId = 1
constructor(private sendRaw: (f: Frame)=>void, private ch=1) {}
call<R=unknown>(method: string, payload: Uint8Array, timeoutMs=5000): Promise<R> {
const id = this.nextId++
const fut = defer<R>()
// 发送请求帧
this.sendRaw({ ch: this.ch, type:'req', id, method, payload })
// 等待相同 id 的响应帧(此处省略路由器实现)
return withTimeout(fut.promise, timeoutMs)
}
// 语法糖:把对象方法“映射”为 call
bind<K extends keyof T>(name: K) {
return async (arg: any) => this.call(name as string, encode(arg))
}
}
// rpc/IPCStub.ts —— 服务端桩:注册方法与处理
export class IPCStub {
private handlers = new Map<string,(arg:any)=>Promise<any>>()
on(method: string, fn: (arg:any)=>Promise<any>) { this.handlers.set(method, fn) }
async handle(frame: Frame, reply: (f: Frame)=>void) {
if (frame.type !== 'req' || !frame.method || !frame.id) return
try {
const fn = this.handlers.get(frame.method)
if (!fn) throw new Error(`No such method: ${frame.method}`)
const result = await fn(decode(frame.payload))
reply({ ch: frame.ch, type:'res', id: frame.id, ok: true, payload: encode(result) })
} catch (e:any) {
reply({ ch: frame.ch, type:'res', id: frame.id, ok: false, err: e?.message ?? 'ERR' })
}
}
}
这样,同一个
DistributedNetwork连接可以复用很多个 channel(1=RPC,2=文件传输,3=心跳…),上层完全不知道下层走的是 SoftBus、洞穿,还是中继。
3.3 与鸿蒙 IPC 的关系
- 同设备/同进程间:照用
IRemoteObject/RemoteObject; - 跨设备:
IPCProxy跑在DistributedNetwork之上; - 如果在局域网且有 DSoftBus,可以把
SoftBusTransport做成透明通道,不改 RPC 层一行代码。
3.4 一个“跨网段 KV 服务”小样
服务端(被调设备):
// app/KVServer.ts
import { IPCStub } from './rpc/IPCStub'
import { DistributedNetwork } from './network/DistributedNetwork'
const kv = new Map<string,string>()
const stub = new IPCStub()
stub.on('kv.put', async ({k,v}) => { kv.set(k,v); return { ok: true } })
stub.on('kv.get', async ({k}) => ({ v: kv.get(k) ?? null }))
// 绑定网络
const dn = new DistributedNetwork()
dn.onMessage = (bytes) => router.route(decodeFrame(bytes), (f)=> dn.send(encodeFrame(f)))
客户端(发起设备):
// app/KVClient.ts
import { IPCProxy } from './rpc/IPCProxy'
import { DistributedNetwork } from './network/DistributedNetwork'
const dn = new DistributedNetwork()
await dn.smartConnect(targetEndpoint)
const proxy = new IPCProxy<{ 'kv.put': (arg:{k:string,v:string})=>Promise<any>,
'kv.get': (arg:{k:string})=>Promise<{v:string|null}> }>(
(f)=> dn.send(encodeFrame(f))
)
await proxy.bind('kv.put')({ k:'hello', v:'Harmony' })
const { v } = await proxy.bind('kv.get')({ k:'hello' })
console.log('value =', v) // => Harmony
上面这套,不管你是 SoftBus 直连、UDP 打洞直连还是云中继,调用姿势完全一致。这就是“可插拔的 RPC”。
四、安全 & 可观测:不能偷工减料的两件事
-
设备身份:首次配对走 PAKE/扫码 交换公钥,之后用 证书/指纹 快速信任
-
握手协议:推荐 Noise XK(或 TLS 1.3),握手完成后派生会话密钥
-
密钥轮换:长会话(>1h)或传输数据量阈值后自动轮换
-
速率限制:信令服务对每设备做 token bucket,避免探测风暴
-
埋点:
- 直连命中率(SoftBus/UDP/TCP/Relay)
- 首包到达时延 P50/P95
- 会话存活时间/重连次数
- NAT 类型分布(匿名统计)
-
降级策略:同一会话连续 2 次丢包率 > 20% 且 RTT 抖动大 → 自动切到 relay,并在后台继续探测直连机会
五、把它放进项目:最小落地步骤(Checklist)
- 抽象传输:把现有“网络调用”改成走
Transport接口(WebSocket 也行) - 接入
DistributedNetwork:加上 SoftBus 封装与洞穿/relay 的并行探测 - 封 RPC:引入
IPCProxy/IPCStub;业务只认“方法名 + 参数” - 信令与 STUN:选一套最省心的(自建或现成);先跑起来再逐步优化 NAT 覆盖
- 加密/鉴权:至少先用 TLS + 设备 token,随后升级到 Noise/双向证书
- 埋点与容灾:所有通道都上指标 + 自动降级
- 灰度:先对内测设备开启“直连优先”,观察 1~2 周,再扩大
六、常见坑(提前替你踩)🕳️
- 把信令与数据放同一条中继:信令拥堵会拖垮数据通道;分开。
- 单线程串行探测:一条条试,超时累加,体验雪崩;并行 + 竞速收敛。
- UDP 打洞成功却被网关回收:没有心跳;20s 左右心跳保活。
- RPC 直接 JSON:大对象开销大、无背压,掉线重入困难;二进制帧 + 请求 id。
- 只做直连不做兜底:一旦遇到对称 NAT 就“黑屏”;relay 必须常备。
- 不做密钥轮换:长连泄露风险高;量/时轮换。
- DSoftBus 成功就万事大吉:跨网后必然失败;把 SoftBus 作为 L0 候选,不要把它当唯一解。
尾声:跨网段不等于“投降给云”,是“按阶梯拿回控制权”
当你把 DistributedNetwork 做成会挑路的梯子,把 NAT 穿透做成并行竞速,再把 IPCProxy 做成传输无感的 RPC,跨网段这件事就从“玄学”变成“工程活”。
…
(未完待续)
更多推荐




所有评论(0)