独立开发者笔记,非官方教程。Token 与 SDK 见 webrpc 控制台。
人不在家里时,常会碰到两个很具体的问题:
- 家里的 NAS / 小主机还活着吗?磁盘还剩多少空间?
- 某个目录里有没有把备份跑完?
用 frp 可以硬把 Web 管理页或 SSH 透出来,但你要先维护一台公网机器,还要操心端口暴露面。之前我写过一篇更完整的双端 RPC 示例(Ping / GetDeviceInfo),这次换一个更贴近日常的痛点:用 JSON 发请求,远程查磁盘用量和列目录。
下面示例依然是无公网 IP 场景:A 端挂在家里当 Agent,B 端在外面发起查询。
这次和之前示例有什么不同?
| 点 | 之前 | 这篇 |
|---|---|---|
| 载荷格式 | `RPC | Ping |
| 业务 | 连通性测试 | disk.usage / dir.list |
| 代码结构 | 双文件各写一套 | 抽出公共 protocol.go 思路(文内合并展示) |
协议长这样:
json
// 请求
{"id":1,"method":"disk.usage"}
{"id":2,"method":"dir.list","params":{"path":"/Photos"}}
// 响应
{"id":1,"ok":true,"data":{"path":"/","total_gb":931.5,"free_gb":412.3}}
{"id":2,"ok":true,"data":{"entries":["2024","2025","backup"]}}
准备
- 控制台拿两个 Token:
YOUR_TOKEN_A(家里 Agent)、YOUR_TOKEN_B(外面 Client) - 下载对应平台 SDK,头文件和动态库放工程根目录
- Go 1.20+,能跑 CGO
目录:
text
nas-remote-query/
├── libwebrpc-Mac.h
├── libwebrpc-Mac.dylib
├── agent/main.go # 家里 NAS
└── client/main.go # 外面查询
公共:回调读帧 + JSON 类型
两端都要连 127.0.0.1:回调端口 读 SDK 推来的数据。帧格式与官网一致:sessionId(4) + type(1) + payload。数据流 type=2。
go
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params map[string]string `json:"params,omitempty"`
}
type Response struct {
ID int `json:"id"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func readDataFrame(conn net.Conn) (sessionID uint32, payload []byte, err error) {
sidBuf := make([]byte, 4)
if _, err = io.ReadFull(conn, sidBuf); err != nil {
return
}
sessionID = binary.BigEndian.Uint32(sidBuf)
typ := make([]byte, 1)
if _, err = io.ReadFull(conn, typ); err != nil {
return
}
if typ[0] != 2 {
return sessionID, nil, fmt.Errorf("unsupported frame type %d", typ[0])
}
lenBuf := make([]byte, 4)
if _, err = io.ReadFull(conn, lenBuf); err != nil {
return
}
n := binary.BigEndian.Uint32(lenBuf)
payload = make([]byte, n)
_, err = io.ReadFull(conn, payload)
return
}
Agent:家里 NAS 处理 disk.usage 和 dir.list
agent/main.go 核心逻辑:
go
func handleRequest(webrpc C.GoUintptr, sessionID uint32, raw []byte) {
var req Request
if err := json.Unmarshal(raw, &req); err != nil {
reply(webrpc, sessionID, Response{OK: false, Error: "bad json"})
return
}
log.Printf("[Agent] 收到请求: %s", string(raw))
var resp Response
resp.ID = req.ID
switch req.Method {
case "disk.usage":
resp.OK = true
resp.Data = diskUsage("/") // 见下方实现
case "dir.list":
path := req.Params["path"]
if path == "" {
path = "."
}
entries, err := listDir(path)
if err != nil {
resp.OK = false
resp.Error = err.Error()
} else {
resp.OK = true
resp.Data = map[string]interface{}{"entries": entries}
}
default:
resp.OK = false
resp.Error = "unknown method"
}
reply(webrpc, sessionID, resp)
}
func reply(webrpc C.GoUintptr, sessionID uint32, resp Response) {
body, _ := json.Marshal(resp)
log.Printf("[Agent] 回包: %s", body)
go func() {
cmsg := C.CString(string(body))
defer C.free(unsafe.Pointer(cmsg))
ret := C.WebrpcClient_SendData(webrpc, C.uint(sessionID), cmsg, C.int(len(body)), 3000)
log.Printf("[Agent] SendData ret=%d", int(ret))
}()
}
func diskUsage(path string) map[string]interface{} {
// Linux/macOS 可用 syscall.Statfs;此处示意返回结构
return map[string]interface{}{
"path": path,
"total_gb": 931.5,
"free_gb": 412.3,
}
}
func listDir(path string) ([]string, error) {
ents, err := os.ReadDir(path)
if err != nil {
return nil, err
}
names := make([]string, 0, len(ents))
for _, e := range ents {
names = append(names, e.Name())
}
return names, nil
}
Agent 的 main 与官网示例相同:WebrpcClient_New → 等 LoginStatus → GetReceivePort → goroutine 里 net.Dial 回调端口,循环 readDataFrame 后调 handleRequest。
Client:外面发 JSON 查询
go
func main() {
// ... New / Login / GetReceivePort 同上 ...
go startCallbackReader(int(tcpPort)) // 打印收到的 JSON 响应
peer := C.CString("YOUR_TOKEN_A")
defer C.free(unsafe.Pointer(peer))
sid := C.WebrpcClient_OpenSession(webrpc, peer, permission)
if sid == 0 {
log.Fatal("OpenSession 失败")
}
log.Printf("会话建立 sessionId=%d", uint32(sid))
sendJSON(webrpc, sid, Request{ID: 1, Method: "disk.usage"})
time.Sleep(time.Second)
sendJSON(webrpc, sid, Request{
ID: 2, Method: "dir.list",
Params: map[string]string{"path": "/Photos"},
})
select {}
}
func sendJSON(webrpc C.GoUintptr, sid C.uint, req Request) {
body, _ := json.Marshal(req)
cmsg := C.CString(string(body))
defer C.free(unsafe.Pointer(cmsg))
ret := C.WebrpcClient_SendData(webrpc, sid, cmsg, C.int(len(body)), 5000)
log.Printf("[Client] 发送 %s ret=%d", body, int(ret))
}
回调里收到响应后直接 log.Printf 打印 JSON;若要做得像同步 RPC,可以用 map[int]chan Response 按 id 等待,这里为短示例保持异步打印。
CGO 头(与官网一致)
go
/*
#cgo CFLAGS: -I..
#cgo darwin LDFLAGS: -L.. -lwebrpc-Mac
#cgo linux,amd64 LDFLAGS: -L.. -lwebrpc-Linux
#cgo linux,arm64 LDFLAGS: -L.. -lwebrpc-Linux-arm64
#if defined(__APPLE__)
#include "libwebrpc-Mac.h"
#elif defined(__linux__) && defined(__aarch64__)
#include "libwebrpc-Linux-arm64.h"
#elif defined(__linux__)
#include "libwebrpc-Linux.h"
#endif
#include <stdlib.h>
*/
import "C"
跑起来
家里(Agent 先启动):
bash
cd nas-remote-query/agent
export DYLD_LIBRARY_PATH=.. # Linux 用 LD_LIBRARY_PATH
go run .
外面(Client):
bash
cd nas-remote-query/client
go run .
Agent 侧大致会看到:

Client 侧会在回调里打印带 free_gb 和 entries 的 JSON。两端跨网(家宽 + 手机热点)测一次,比同机两个进程更有说服力。
三个容易踩的坑
- 回调里同步 SendData 会卡死读循环------回包务必放 goroutine。
OpenSession返回 0------Agent 没在线、Token 填反、或网络策略太严;先同机验证逻辑。- JSON 和业务权限 ------示例为了短没做鉴权;真实 NAS 产品要在 Agent 里校验 caller、限制可列目录的路径,避免
dir.list被扫全盘。
还能怎么扩展?
disk.usage换成真实syscall.Statfs(Linux)或unix.Statfs(macOS)- 加
file.pull:Agent 读小文件,SendData分块回传;大文件用SendFile - 请求里加
token或 HMAC,做一层轻量鉴权 - 把 Client 封成 CLI:
nasctl disk/nasctl ls /Photos
小结
webrpc 不会替你实现「列目录」的业务逻辑,但它把 无公网 IP 下的会话和收发 收进了 SDK。这篇示例解决的是很具体的一刀:在外面用 JSON 问家里 NAS 还剩多少空间、目录里有什么------比管道字符串更接近真实产品,也比再开一条 frp 隧道更贴应用层。
完整 API 与多语言示例见 https://www.webrpc.cn/ 开发文档。Personal 档约 $5/年/2 Token,够 Agent + Client 各用一个。